mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-04-01 04:15:42 +00:00
feat: Bluesky-inspired feed redesign - opaque surface for swipe, large icons with labels, clean layout
This commit is contained in:
parent
71d58008c6
commit
5ab2cbafdc
1 changed files with 312 additions and 248 deletions
|
|
@ -4,16 +4,23 @@ import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.expandVertically
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.shrinkVertically
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import com.swoosh.microblog.ui.animation.SwooshMotion
|
import com.swoosh.microblog.ui.animation.SwooshMotion
|
||||||
|
|
@ -1422,34 +1429,45 @@ fun PostCardContent(
|
||||||
onClickLabel = "View post details",
|
onClickLabel = "View post details",
|
||||||
onLongClick = { showContextMenu = true }
|
onLongClick = { showContextMenu = true }
|
||||||
)
|
)
|
||||||
.padding(horizontal = 20.dp, vertical = 14.dp)
|
|
||||||
) {
|
) {
|
||||||
// Top row: pin icon (if pinned) + timestamp
|
// Padded content area
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp)
|
||||||
|
) {
|
||||||
|
// Top row: pin indicator (if pinned) + timestamp
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
if (post.featured) {
|
if (post.featured) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.PushPin,
|
imageVector = Icons.Filled.PushPin,
|
||||||
contentDescription = "Pinned",
|
contentDescription = "Pinned",
|
||||||
modifier = Modifier.size(14.dp),
|
modifier = Modifier.size(14.dp),
|
||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Pinned",
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Spacer(modifier = Modifier.width(1.dp))
|
Spacer(modifier = Modifier.width(1.dp))
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = formatRelativeTime(post.publishedAt ?: post.createdAt),
|
text = formatRelativeTime(post.publishedAt ?: post.createdAt),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
// Content — the star of the show
|
// Content -- the star of the show
|
||||||
if (highlightQuery != null && highlightQuery.isNotBlank()) {
|
if (highlightQuery != null && highlightQuery.isNotBlank()) {
|
||||||
HighlightedText(
|
HighlightedText(
|
||||||
text = displayText,
|
text = displayText,
|
||||||
|
|
@ -1460,25 +1478,27 @@ fun PostCardContent(
|
||||||
Text(
|
Text(
|
||||||
text = displayText,
|
text = displayText,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = if (expanded) Int.MAX_VALUE else 8,
|
maxLines = if (expanded) Int.MAX_VALUE else 8,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!expanded && post.textContent.length > 280) {
|
if (!expanded && post.textContent.length > 280) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Show more",
|
text = "Show more",
|
||||||
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
|
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clickable { expanded = true }
|
.clickable { expanded = true }
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 2.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image grid
|
// Image grid
|
||||||
if (allImages.isNotEmpty()) {
|
if (allImages.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
PostImageGrid(
|
PostImageGrid(
|
||||||
images = allImages,
|
images = allImages,
|
||||||
onImageClick = { index ->
|
onImageClick = { index ->
|
||||||
|
|
@ -1511,28 +1531,9 @@ fun PostCardContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hashtag chips
|
|
||||||
if (post.tags.isNotEmpty()) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
FlowRow(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
post.tags.forEach { tag ->
|
|
||||||
Text(
|
|
||||||
text = "#$tag",
|
|
||||||
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold),
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.clickable { onTagClick(tag) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Link preview
|
// Link preview
|
||||||
if (post.linkUrl != null && post.linkTitle != null) {
|
if (post.linkUrl != null && post.linkTitle != null) {
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(modifier = Modifier.padding(12.dp)) {
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
if (post.linkImageUrl != null) {
|
if (post.linkImageUrl != null) {
|
||||||
|
|
@ -1566,6 +1567,25 @@ fun PostCardContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hashtag tags (bold colored text, not chips)
|
||||||
|
if (post.tags.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
post.tags.forEach { tag ->
|
||||||
|
Text(
|
||||||
|
text = "#$tag",
|
||||||
|
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.clickable { onTagClick(tag) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Queue status
|
// Queue status
|
||||||
if (post.queueStatus != QueueStatus.NONE) {
|
if (post.queueStatus != QueueStatus.NONE) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
@ -1589,9 +1609,9 @@ fun PostCardContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
// Metadata line: status dot · reading time
|
// Status line: colored dot + status text + reading time
|
||||||
val stats = remember(post.textContent, post.imageUrl, post.linkUrl) {
|
val stats = remember(post.textContent, post.imageUrl, post.linkUrl) {
|
||||||
PostStats.fromFeedPost(post)
|
PostStats.fromFeedPost(post)
|
||||||
}
|
}
|
||||||
|
|
@ -1612,7 +1632,7 @@ fun PostCardContent(
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(8.dp)
|
.size(8.dp)
|
||||||
.clip(RoundedCornerShape(4.dp))
|
.clip(CircleShape)
|
||||||
.background(statusColor)
|
.background(statusColor)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -1620,7 +1640,11 @@ fun PostCardContent(
|
||||||
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
|
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
|
||||||
color = statusColor
|
color = statusColor
|
||||||
)
|
)
|
||||||
Text("·", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(
|
||||||
|
text = "·",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
text = PostStats.formatReadingTime(stats.readingTimeMinutes),
|
text = PostStats.formatReadingTime(stats.readingTimeMinutes),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
|
@ -1628,61 +1652,101 @@ fun PostCardContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
// Action bar separator
|
// Action bar separator
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||||
|
|
||||||
// Action bar — evenly spaced icons
|
// Action bar -- evenly spaced icons with labels
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 6.dp),
|
.padding(top = 8.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onEdit, modifier = Modifier.size(40.dp)) {
|
// Edit action
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.clickable(onClick = onEdit)
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Edit,
|
Icons.Default.Edit,
|
||||||
contentDescription = "Edit",
|
contentDescription = "Edit",
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
Text(
|
||||||
|
text = "Edit",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copy link action (only for published posts with URL)
|
||||||
if (isPublished && hasShareableUrl) {
|
if (isPublished && hasShareableUrl) {
|
||||||
IconButton(onClick = {
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.clickable {
|
||||||
onCopyLink()
|
onCopyLink()
|
||||||
snackbarHostState?.let { host ->
|
snackbarHostState?.let { host ->
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
host.showSnackbar("Link copied")
|
host.showSnackbar("Link copied")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, modifier = Modifier.size(40.dp)) {
|
}
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.ContentCopy,
|
Icons.Default.ContentCopy,
|
||||||
contentDescription = "Copy link",
|
contentDescription = "Copy link",
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
Text(
|
||||||
|
text = "Copy",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = onShare, modifier = Modifier.size(40.dp)) {
|
|
||||||
|
// Share action
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.clickable(onClick = onShare)
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Share,
|
Icons.Default.Share,
|
||||||
contentDescription = "Share",
|
contentDescription = "Share",
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
Text(
|
||||||
}
|
text = "Share",
|
||||||
IconButton(onClick = onTogglePin, modifier = Modifier.size(40.dp)) {
|
style = MaterialTheme.typography.labelSmall,
|
||||||
Icon(
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
imageVector = if (post.featured) Icons.Filled.PushPin else Icons.Outlined.PushPin,
|
|
||||||
contentDescription = if (post.featured) "Unpin" else "Pin",
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
tint = if (post.featured) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Context menu (long-press)
|
// Pin/Unpin action
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.clickable(onClick = onTogglePin)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (post.featured) Icons.Filled.PushPin else Icons.Outlined.PushPin,
|
||||||
|
contentDescription = if (post.featured) "Unpin" else "Pin",
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = if (post.featured) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (post.featured) "Unpin" else "Pin",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (post.featured) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context menu (long-press) -- only Delete, other actions are in the action bar
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = showContextMenu,
|
expanded = showContextMenu,
|
||||||
onDismissRequest = { showContextMenu = false }
|
onDismissRequest = { showContextMenu = false }
|
||||||
|
|
@ -1697,12 +1761,12 @@ fun PostCardContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thick separator between posts
|
// Full-width thick divider between posts (outside padded column)
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
modifier = Modifier.padding(horizontal = 0.dp),
|
thickness = 1.dp,
|
||||||
thickness = 2.dp,
|
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
||||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Gallery viewer
|
// Gallery viewer
|
||||||
if (showGallery && allImages.isNotEmpty()) {
|
if (showGallery && allImages.isNotEmpty()) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue