mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
feat: add share sheet with share intent, copy link, and URL construction
This commit is contained in:
parent
74f42fd2f1
commit
9da3b0da0f
7 changed files with 640 additions and 98 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(
|
data class GhostPost(
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
val title: String? = null,
|
val title: String? = null,
|
||||||
|
val slug: String? = null,
|
||||||
|
val url: String? = null,
|
||||||
val html: String? = null,
|
val html: String? = null,
|
||||||
val plaintext: String? = null,
|
val plaintext: String? = null,
|
||||||
val mobiledoc: String? = null,
|
val mobiledoc: String? = null,
|
||||||
|
|
@ -91,6 +93,8 @@ enum class QueueStatus {
|
||||||
data class FeedPost(
|
data class FeedPost(
|
||||||
val localId: Long? = null,
|
val localId: Long? = null,
|
||||||
val ghostId: String? = null,
|
val ghostId: String? = null,
|
||||||
|
val slug: String? = null,
|
||||||
|
val url: String? = null,
|
||||||
val title: String,
|
val title: String,
|
||||||
val textContent: String,
|
val textContent: String,
|
||||||
val htmlContent: String?,
|
val htmlContent: String?,
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,34 @@
|
||||||
package com.swoosh.microblog.ui.detail
|
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.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
import com.swoosh.microblog.data.CredentialsManager
|
||||||
|
import com.swoosh.microblog.data.ShareUtils
|
||||||
import com.swoosh.microblog.data.model.FeedPost
|
import com.swoosh.microblog.data.model.FeedPost
|
||||||
|
import com.swoosh.microblog.data.model.QueueStatus
|
||||||
import com.swoosh.microblog.ui.feed.StatusBadge
|
import com.swoosh.microblog.ui.feed.StatusBadge
|
||||||
import com.swoosh.microblog.ui.feed.formatRelativeTime
|
import com.swoosh.microblog.ui.feed.formatRelativeTime
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -27,6 +39,15 @@ fun DetailScreen(
|
||||||
onDelete: (FeedPost) -> Unit
|
onDelete: (FeedPost) -> Unit
|
||||||
) {
|
) {
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
|
|
@ -38,15 +59,67 @@ fun DetailScreen(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
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) }) {
|
IconButton(onClick = { onEdit(post) }) {
|
||||||
Icon(Icons.Default.Edit, "Edit")
|
Icon(Icons.Default.Edit, "Edit")
|
||||||
}
|
}
|
||||||
IconButton(onClick = { showDeleteDialog = true }) {
|
// Overflow menu with Copy link and Delete
|
||||||
Icon(Icons.Default.Delete, "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 ->
|
) { padding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -132,7 +205,7 @@ fun DetailScreen(
|
||||||
|
|
||||||
// Metadata
|
// Metadata
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Divider()
|
HorizontalDivider()
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
if (post.createdAt != null) {
|
if (post.createdAt != null) {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,23 @@
|
||||||
package com.swoosh.microblog.ui.feed
|
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 androidx.compose.foundation.layout.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Settings
|
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.icons.filled.WifiOff
|
||||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||||
import androidx.compose.material.pullrefresh.pullRefresh
|
import androidx.compose.material.pullrefresh.pullRefresh
|
||||||
|
|
@ -20,12 +28,16 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.DpOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
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.FeedPost
|
||||||
import com.swoosh.microblog.data.model.QueueStatus
|
import com.swoosh.microblog.data.model.QueueStatus
|
||||||
|
|
||||||
|
|
@ -39,6 +51,9 @@ fun FeedScreen(
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val baseUrl = remember { CredentialsManager(context).ghostUrl }
|
||||||
|
|
||||||
// Pull-to-refresh
|
// Pull-to-refresh
|
||||||
val pullRefreshState = rememberPullRefreshState(
|
val pullRefreshState = rememberPullRefreshState(
|
||||||
|
|
@ -78,7 +93,8 @@ fun FeedScreen(
|
||||||
FloatingActionButton(onClick = onCompose) {
|
FloatingActionButton(onClick = onCompose) {
|
||||||
Icon(Icons.Default.Add, contentDescription = "New post")
|
Icon(Icons.Default.Add, contentDescription = "New post")
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -151,7 +167,26 @@ fun FeedScreen(
|
||||||
PostCard(
|
PostCard(
|
||||||
post = post,
|
post = post,
|
||||||
onClick = { onPostClick(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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,28 +226,45 @@ fun FeedScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PostCard(
|
fun PostCard(
|
||||||
post: FeedPost,
|
post: FeedPost,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onCancelQueue: () -> Unit
|
onCancelQueue: () -> Unit,
|
||||||
|
onShare: () -> Unit = {},
|
||||||
|
onCopyLink: () -> Unit = {},
|
||||||
|
snackbarHostState: SnackbarHostState? = null
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
var showContextMenu by remember { mutableStateOf(false) }
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val displayText = if (expanded || post.textContent.length <= 280) {
|
val displayText = if (expanded || post.textContent.length <= 280) {
|
||||||
post.textContent
|
post.textContent
|
||||||
} else {
|
} else {
|
||||||
post.textContent.take(280) + "..."
|
post.textContent.take(280) + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isPublished = post.status == "published" && post.queueStatus == QueueStatus.NONE
|
||||||
|
val hasShareableUrl = !post.slug.isNullOrBlank() || !post.url.isNullOrBlank()
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
.clickable(onClick = onClick),
|
.combinedClickable(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = {
|
||||||
|
if (isPublished && hasShareableUrl) {
|
||||||
|
showContextMenu = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
Box {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
// Status row
|
// Status row
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -320,6 +372,61 @@ fun PostCard(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private fun GhostPost.toFeedPost(): FeedPost = FeedPost(
|
private fun GhostPost.toFeedPost(): FeedPost = FeedPost(
|
||||||
ghostId = id,
|
ghostId = id,
|
||||||
|
slug = slug,
|
||||||
|
url = url,
|
||||||
title = title ?: "",
|
title = title ?: "",
|
||||||
textContent = plaintext ?: html?.replace(Regex("<[^>]*>"), "") ?: "",
|
textContent = plaintext ?: html?.replace(Regex("<[^>]*>"), "") ?: "",
|
||||||
htmlContent = html,
|
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()
|
val post = GhostPost()
|
||||||
assertNull(post.id)
|
assertNull(post.id)
|
||||||
assertNull(post.title)
|
assertNull(post.title)
|
||||||
|
assertNull(post.slug)
|
||||||
|
assertNull(post.url)
|
||||||
assertNull(post.html)
|
assertNull(post.html)
|
||||||
assertNull(post.plaintext)
|
assertNull(post.plaintext)
|
||||||
assertNull(post.mobiledoc)
|
assertNull(post.mobiledoc)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue