merge: integrate share sheet feature (resolve conflicts)

This commit is contained in:
Paweł Orzech 2026-03-19 10:39:28 +01:00
commit 0e954e15d5
No known key found for this signature in database
7 changed files with 639 additions and 97 deletions

View 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
}
}

View file

@ -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?,

View file

@ -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

View file

@ -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,28 +246,45 @@ 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
)
) {
Box {
Column(modifier = Modifier.padding(16.dp)) {
// Status row
Row(
@ -341,6 +393,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)
}
)
}
// Post stats badges
if (post.textContent.isNotBlank()) {
val stats = remember(post.textContent, post.imageUrl, post.linkUrl) {

View file

@ -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,

View 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
)
}

View file

@ -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)