feat(desktop): tabs-first header for Home / Reads, compact Notifications header

Refactored the three feed screen headers so they all match the Messages
pattern — single compact row padded horizontal = 12, vertical = 8, no
oversized titles, no multi-row metadata.

FeedScreen (Home):
- Replaced the `headlineMedium` "Global Feed" / "Following Feed" title
  plus a redundant Global/Following chip row plus a separate relay-count
  meta-line plus a large "New Post" button with:
    [ Following | Global ]   [relays] [refresh] [+ compose]
  The selected tab IS the screen title. Relay count + followed-user
  count surface through a hover tooltip on the relays icon (desktop
  convention; info is preserved without taking a whole row).
- Relays icon click opens the picker when the account can edit, falls
  through to navigate-to-relays otherwise.

ReadsScreen:
- Same treatment: dropped the "Reads" label, lifted Following/Global
  chips to the left, single refresh icon on the right. "N relays
  connected" moved to the refresh button's content description.

commons/FeedHeader (used by NotificationsScreen):
- Dropped the RelayStatusIndicator (icon + count text + refresh button,
  took 3 slots). Now just titleMedium on the left + a single refresh
  IconButton on the right with relay count in its content description,
  matching the Messages "titleMedium + icon buttons" rhythm.
- Internal padding(horizontal = 12, vertical = 8) baked in so callers
  don't need a trailing Spacer.

https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
This commit is contained in:
Claude
2026-04-24 14:23:32 +00:00
parent 8fb16bc7a3
commit b198385849
4 changed files with 134 additions and 189 deletions
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.ui.feed
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -34,10 +35,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.theme.RelayStatusColors
/** /**
* Header component for feed screens with title and relay connection status. * Compact Messages-style header for feed screens: titleMedium on the left,
* a single refresh IconButton on the right. Relay count is rolled into the
* refresh button's content description so it still surfaces in hover tooltips
* / accessibility readers without stealing a whole row.
* *
* @param title The feed title * @param title The feed title
* @param connectedRelayCount Number of connected relays * @param connectedRelayCount Number of connected relays
@@ -52,62 +55,25 @@ fun FeedHeader(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
title, title,
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
) )
RelayStatusIndicator( IconButton(
connectedCount = connectedRelayCount, onClick = onRefresh,
onRefresh = onRefresh, modifier = Modifier.size(32.dp),
) ) {
}
}
/**
* Compact relay connection status indicator with refresh button.
*/
@Composable
fun RelayStatusIndicator(
connectedCount: Int,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val statusColor =
when {
connectedCount == 0 -> RelayStatusColors.Disconnected
connectedCount < 3 -> RelayStatusColors.Connecting
else -> RelayStatusColors.Connected
}
Icon(
symbol = if (connectedCount > 0) MaterialSymbols.Check else MaterialSymbols.Close,
contentDescription = null,
tint = statusColor,
modifier = Modifier.size(16.dp),
)
Text(
"$connectedCount relay${if (connectedCount != 1) "s" else ""}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
IconButton(onClick = onRefresh) {
Icon( Icon(
MaterialSymbols.Refresh, MaterialSymbols.Refresh,
contentDescription = "Reconnect", contentDescription = "Refresh ($connectedRelayCount relays connected)",
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
) )
} }
} }
@@ -20,12 +20,11 @@
*/ */
package com.vitorpamplona.amethyst.desktop.ui package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.clickable import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.TooltipArea
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
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -33,15 +32,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
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.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -489,7 +487,6 @@ fun FeedScreen(
onDispose { subId?.let { coordinator.releaseInteractions(it) } } onDispose { subId?.let { coordinator.releaseInteractions(it) } }
} }
@OptIn(ExperimentalLayoutApi::class)
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Header with compose button // Header with compose button
@@ -629,9 +626,15 @@ fun FeedScreen(
} }
/** /**
* Feed header with title, mode selector, relay count, and compose button. * Feed header \u2014 Messages-style single row: Following/Global tabs on the left,
* compact icon buttons on the right (relays, refresh, compose). The selected
* tab IS the title; no redundant "Global Feed" / "Following Feed" label.
*
* Relay count and followed-users count are surfaced via a hover tooltip on
* the relays icon (desktop convention \u2014 the at-a-glance info is preserved
* without stealing header real estate).
*/ */
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun FeedHeader( private fun FeedHeader(
feedMode: FeedMode, feedMode: FeedMode,
@@ -644,90 +647,96 @@ private fun FeedHeader(
onNavigateToRelays: () -> Unit = {}, onNavigateToRelays: () -> Unit = {},
onOpenRelayPicker: () -> Unit = {}, onOpenRelayPicker: () -> Unit = {},
) { ) {
FlowRow( Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically,
) { ) {
Column { // Tabs \u2014 the selected one is the screen title.
FlowRow( Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
verticalArrangement = Arrangement.Center, if (account != null) {
horizontalArrangement = Arrangement.spacedBy(8.dp), FilterChip(
) { selected = feedMode == FeedMode.FOLLOWING,
Text( onClick = { onFeedModeChange(FeedMode.FOLLOWING) },
if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed", label = { Text("Following") },
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
) )
if (account != null) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilterChip(
selected = feedMode == FeedMode.GLOBAL,
onClick = { onFeedModeChange(FeedMode.GLOBAL) },
label = { Text("Global") },
)
FilterChip(
selected = feedMode == FeedMode.FOLLOWING,
onClick = { onFeedModeChange(FeedMode.FOLLOWING) },
label = { Text("Following") },
)
}
}
}
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${feedRelays.size} relays",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier =
Modifier.clickable { onNavigateToRelays() },
)
if (feedMode == FeedMode.FOLLOWING) {
Text(
" \u2022 $followedUsersCount followed",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(8.dp))
if (account != null && !account.isReadOnly) {
IconButton(
onClick = onOpenRelayPicker,
modifier = Modifier.size(24.dp),
) {
Icon(
MaterialSymbols.Dns,
contentDescription = "Edit Feed Relays",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
Spacer(Modifier.width(4.dp))
}
IconButton(
onClick = onRefresh,
modifier = Modifier.size(24.dp),
) {
Icon(
MaterialSymbols.Refresh,
contentDescription = "Refresh",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
} }
FilterChip(
selected = feedMode == FeedMode.GLOBAL,
onClick = { onFeedModeChange(FeedMode.GLOBAL) },
label = { Text("Global") },
)
} }
Button( // Actions \u2014 compact icon buttons at the same scale as the Messages header.
onClick = onCompose, Row(verticalAlignment = Alignment.CenterVertically) {
enabled = account != null && !account.isReadOnly, val relaysTooltip =
) { buildString {
Icon(MaterialSymbols.Add, "New Post", Modifier.size(18.dp)) append("${feedRelays.size} relays")
Spacer(Modifier.width(8.dp)) if (feedMode == FeedMode.FOLLOWING) {
Text("New Post") append(" \u2022 $followedUsersCount followed")
}
}
TooltipArea(
tooltip = {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 2.dp,
shadowElevation = 4.dp,
) {
Text(
relaysTooltip,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.bodySmall,
)
}
},
) {
IconButton(
onClick = {
if (account != null && !account.isReadOnly) {
onOpenRelayPicker()
} else {
onNavigateToRelays()
}
},
modifier = Modifier.size(32.dp),
) {
Icon(
MaterialSymbols.Dns,
contentDescription = relaysTooltip,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
}
IconButton(
onClick = onRefresh,
modifier = Modifier.size(32.dp),
) {
Icon(
MaterialSymbols.Refresh,
contentDescription = "Refresh",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
if (account != null && !account.isReadOnly) {
IconButton(
onClick = onCompose,
modifier = Modifier.size(32.dp),
) {
Icon(
MaterialSymbols.Add,
contentDescription = "New Post",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
}
} }
} }
} }
@@ -245,8 +245,6 @@ fun NotificationsScreen(
onRefresh = { relayManager.connect() }, onRefresh = { relayManager.connect() },
) )
Spacer(Modifier.height(16.dp))
if (connectedRelays.isEmpty()) { if (connectedRelays.isEmpty()) {
LoadingState("Connecting to relays...") LoadingState("Connecting to relays...")
} else if (notifications.isEmpty() && !initialLoadComplete) { } else if (notifications.isEmpty() && !initialLoadComplete) {
@@ -23,8 +23,6 @@ package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -32,7 +30,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
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.material3.Card import androidx.compose.material3.Card
@@ -290,70 +287,45 @@ fun ReadsScreen(
} }
} }
@OptIn(ExperimentalLayoutApi::class)
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Header — wraps on narrow columns // Header — Messages-style: tabs left, refresh right. The selected tab
FlowRow( // (Following / Global) acts as the screen title, so no separate label.
Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically,
) { ) {
Column { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FlowRow( if (account != null) {
verticalArrangement = Arrangement.Center, FilterChip(
horizontalArrangement = Arrangement.spacedBy(8.dp), selected = feedMode == FeedMode.FOLLOWING,
) { onClick = { feedMode = FeedMode.FOLLOWING },
Text( label = { Text("Following") },
"Reads",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
) )
// Feed mode selector
if (account != null) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilterChip(
selected = feedMode == FeedMode.GLOBAL,
onClick = { feedMode = FeedMode.GLOBAL },
label = { Text("Global") },
)
FilterChip(
selected = feedMode == FeedMode.FOLLOWING,
onClick = { feedMode = FeedMode.FOLLOWING },
label = { Text("Following") },
)
}
}
} }
FilterChip(
selected = feedMode == FeedMode.GLOBAL,
onClick = { feedMode = FeedMode.GLOBAL },
label = { Text("Global") },
)
}
Spacer(Modifier.height(4.dp)) IconButton(
Row(verticalAlignment = Alignment.CenterVertically) { onClick = { relayManager.connect() },
Text( modifier = Modifier.size(32.dp),
"${connectedRelays.size} relays connected", ) {
style = MaterialTheme.typography.bodyMedium, Icon(
color = MaterialTheme.colorScheme.onSurfaceVariant, MaterialSymbols.Refresh,
) contentDescription = "Refresh (${connectedRelays.size} relays connected)",
Spacer(Modifier.width(8.dp)) tint = MaterialTheme.colorScheme.primary,
IconButton( modifier = Modifier.size(20.dp),
onClick = { relayManager.connect() }, )
modifier = Modifier.size(24.dp),
) {
Icon(
MaterialSymbols.Refresh,
contentDescription = "Refresh",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
}
} }
} }
Spacer(Modifier.height(8.dp))
when { when {
connectedRelays.isEmpty() -> { connectedRelays.isEmpty() -> {
LoadingState("Connecting to relays...") LoadingState("Connecting to relays...")