fix(ui): show full post text, scrollable profile header with floating bar
- Remove maxLines=10 on post text so content is never truncated - Profile header/card/tabs now scroll with content instead of staying sticky — scrolls away naturally as you browse posts - Floating compact header (back + avatar + name) slides in when scrolling up and the full header is out of view Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+92
-18
@@ -20,6 +20,10 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.ui
|
package com.vitorpamplona.amethyst.desktop.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -33,6 +37,7 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
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.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.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
@@ -339,12 +344,42 @@ fun UserProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
// Scroll state for detecting scroll direction
|
||||||
// Broadcast banner for profile updates
|
val listState = rememberLazyListState()
|
||||||
|
var showFloatingHeader by remember { mutableStateOf(false) }
|
||||||
|
var previousFirstVisibleItemIndex by remember { mutableStateOf(0) }
|
||||||
|
var previousFirstVisibleItemScrollOffset by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
|
// Show floating header when scrolling up and header is scrolled out of view
|
||||||
|
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
|
||||||
|
val currentIndex = listState.firstVisibleItemIndex
|
||||||
|
val currentOffset = listState.firstVisibleItemScrollOffset
|
||||||
|
val scrollingUp =
|
||||||
|
currentIndex < previousFirstVisibleItemIndex ||
|
||||||
|
(currentIndex == previousFirstVisibleItemIndex && currentOffset < previousFirstVisibleItemScrollOffset)
|
||||||
|
|
||||||
|
// Header items are indices 0-3, so if first visible >= 3, header is out of view
|
||||||
|
showFloatingHeader = scrollingUp && currentIndex >= 3
|
||||||
|
if (!scrollingUp && currentIndex < 3) showFloatingHeader = false
|
||||||
|
|
||||||
|
previousFirstVisibleItemIndex = currentIndex
|
||||||
|
previousFirstVisibleItemScrollOffset = currentOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
if (connectedRelays.isEmpty()) {
|
||||||
|
LoadingState("Connecting to relays...")
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
// Broadcast banner
|
||||||
|
item(key = "broadcast") {
|
||||||
ProfileBroadcastBanner(
|
ProfileBroadcastBanner(
|
||||||
status = broadcastStatus,
|
status = broadcastStatus,
|
||||||
onTap = {
|
onTap = {
|
||||||
// Clear banner on tap (could add retry logic for failed)
|
|
||||||
if (broadcastStatus is ProfileBroadcastStatus.Success ||
|
if (broadcastStatus is ProfileBroadcastStatus.Success ||
|
||||||
broadcastStatus is ProfileBroadcastStatus.Failed
|
broadcastStatus is ProfileBroadcastStatus.Failed
|
||||||
) {
|
) {
|
||||||
@@ -352,10 +387,12 @@ fun UserProfileScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Header with back button
|
// Header with back button
|
||||||
|
item(key = "header") {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
@@ -469,11 +506,10 @@ fun UserProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (connectedRelays.isEmpty()) {
|
|
||||||
LoadingState("Connecting to relays...")
|
|
||||||
} else {
|
|
||||||
// Profile card
|
// Profile card
|
||||||
|
item(key = "profile-card") {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors =
|
colors =
|
||||||
@@ -486,7 +522,6 @@ fun UserProfileScreen(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.Top,
|
verticalAlignment = Alignment.Top,
|
||||||
) {
|
) {
|
||||||
// Profile picture with robohash fallback
|
|
||||||
UserAvatar(
|
UserAvatar(
|
||||||
userHex = pubKeyHex,
|
userHex = pubKeyHex,
|
||||||
pictureUrl = picture,
|
pictureUrl = picture,
|
||||||
@@ -504,7 +539,6 @@ fun UserProfileScreen(
|
|||||||
val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub()
|
val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub()
|
||||||
var copied by remember { mutableStateOf(false) }
|
var copied by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Reset copied state after delay
|
|
||||||
LaunchedEffect(copied) {
|
LaunchedEffect(copied) {
|
||||||
if (copied) {
|
if (copied) {
|
||||||
delay(2000)
|
delay(2000)
|
||||||
@@ -586,10 +620,10 @@ fun UserProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
// Tabs
|
||||||
|
item(key = "tabs") {
|
||||||
// Notes / Gallery tabs
|
|
||||||
PrimaryTabRow(selectedTabIndex = selectedTab) {
|
PrimaryTabRow(selectedTabIndex = selectedTab) {
|
||||||
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) {
|
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) {
|
||||||
Text("Notes", modifier = Modifier.padding(12.dp))
|
Text("Notes", modifier = Modifier.padding(12.dp))
|
||||||
@@ -598,13 +632,14 @@ fun UserProfileScreen(
|
|||||||
Text("Gallery", modifier = Modifier.padding(12.dp))
|
Text("Gallery", modifier = Modifier.padding(12.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
// Tab content
|
||||||
|
|
||||||
when (selectedTab) {
|
when (selectedTab) {
|
||||||
0 -> {
|
0 -> {
|
||||||
when {
|
when {
|
||||||
postsError != null -> {
|
postsError != null -> {
|
||||||
|
item(key = "error") {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
@@ -628,8 +663,10 @@ fun UserProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
postsLoading -> {
|
postsLoading -> {
|
||||||
|
item(key = "loading") {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
@@ -645,8 +682,10 @@ fun UserProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
events.isEmpty() -> {
|
events.isEmpty() -> {
|
||||||
|
item(key = "empty") {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
@@ -658,11 +697,9 @@ fun UserProfileScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
LazyColumn(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
||||||
FeedNoteCard(
|
FeedNoteCard(
|
||||||
event = event,
|
event = event,
|
||||||
@@ -684,9 +721,9 @@ fun UserProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
1 -> {
|
1 -> {
|
||||||
|
item(key = "gallery") {
|
||||||
GalleryTab(
|
GalleryTab(
|
||||||
pictureEvents = pictureEvents,
|
pictureEvents = pictureEvents,
|
||||||
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
|
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
|
||||||
@@ -695,6 +732,43 @@ fun UserProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating header — appears on scroll up when profile header is out of view
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showFloatingHeader,
|
||||||
|
enter = slideInVertically { -it },
|
||||||
|
exit = slideOutVertically { -it },
|
||||||
|
modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
UserAvatar(
|
||||||
|
userHex = pubKeyHex,
|
||||||
|
pictureUrl = picture,
|
||||||
|
size = 28.dp,
|
||||||
|
contentDescription = "Profile picture",
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
displayName ?: pubKeyHex.take(12) + "...",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Lightbox overlay
|
// Lightbox overlay
|
||||||
lightboxState?.let { state ->
|
lightboxState?.let { state ->
|
||||||
|
|||||||
+1
-1
@@ -285,7 +285,7 @@ fun RichTextContent(
|
|||||||
content: String,
|
content: String,
|
||||||
urls: Urls,
|
urls: Urls,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
maxLines: Int = 10,
|
maxLines: Int = Int.MAX_VALUE,
|
||||||
) {
|
) {
|
||||||
if (urls.withScheme.isEmpty()) {
|
if (urls.withScheme.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
Reference in New Issue
Block a user