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:
nrobi144
2026-03-17 15:26:20 +02:00
parent 2aa84c98ad
commit bdd1feae3e
2 changed files with 381 additions and 307 deletions
@@ -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 ->
@@ -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(