Merge pull request #2629 from vitorpamplona/claude/expandable-nestfullscreen-summary-H38Ey

Refactor nest room UI: collapsible summary and stage header redesign
This commit is contained in:
Vitor Pamplona
2026-04-28 15:58:31 -04:00
committed by GitHub
2 changed files with 79 additions and 68 deletions
@@ -20,14 +20,12 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.screen package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.screen
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.clickable
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.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox import androidx.compose.material3.BadgedBox
@@ -39,7 +37,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -52,11 +49,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
@@ -86,11 +81,13 @@ import kotlinx.coroutines.launch
/** /**
* Full-screen layout for [com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestActivity]. Vertically split into: * Full-screen layout for [com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestActivity]. Vertically split into:
* *
* 1. TopAppBar — room title + overflow menu (Share, host's Edit). * 1. TopAppBar — room title (tap to expand/collapse summary) + overflow
* 2. Header strip — LIVE chip, listener count, optional 1-line summary. * menu (Share, host's Edit).
* 3. Stage — vertical adaptive grid of host/speakers (height-bounded * 2. Optional summary strip — only visible when the user taps the title.
* so a 30-speaker room scrolls inside the strip and never pushes * 3. Stage — vertical adaptive grid of host/speakers, with the LIVE
* the chat below the fold). * chip and listener count rendered in the stage header row
* (height-bounded so a 30-speaker room scrolls inside the strip
* and never pushes the chat below the fold).
* 4. Tabs — `Chat | Audience · N | Hands · N` (Hands host-only, * 4. Tabs — `Chat | Audience · N | Hands · N` (Hands host-only,
* shown only while there's at least one raised hand). * shown only while there's at least one raised hand).
* 5. Tab content — fills the remaining vertical space: * 5. Tab content — fills the remaining vertical space:
@@ -122,6 +119,11 @@ internal fun NestFullScreen(
var showHostLeaveConfirm by rememberSaveable { mutableStateOf(false) } var showHostLeaveConfirm by rememberSaveable { mutableStateOf(false) }
var showReactionPicker by rememberSaveable { mutableStateOf(false) } var showReactionPicker by rememberSaveable { mutableStateOf(false) }
var hostMenuTarget by rememberSaveable { mutableStateOf<String?>(null) } var hostMenuTarget by rememberSaveable { mutableStateOf<String?>(null) }
// Summary is collapsed by default; tapping the top-bar title
// toggles it so the user can preview the room description without
// opening a separate sheet, then collapse again to reclaim vertical
// room for the stage and chat.
var summaryExpanded by rememberSaveable { mutableStateOf(false) }
// Tab selection survives configuration changes and PIP transitions. // Tab selection survives configuration changes and PIP transitions.
// Stored as ordinal so rememberSaveable can persist it without a // Stored as ordinal so rememberSaveable can persist it without a
// custom Saver. // custom Saver.
@@ -187,6 +189,7 @@ internal fun NestFullScreen(
onMinimize = onMinimize, onMinimize = onMinimize,
onMenuOpen = { showHostMenu = true }, onMenuOpen = { showHostMenu = true },
onMenuDismiss = { showHostMenu = false }, onMenuDismiss = { showHostMenu = false },
onTitleClick = { summaryExpanded = !summaryExpanded },
onShare = { onShare = {
showHostMenu = false showHostMenu = false
shareRoomNaddr(topBarContext, event) shareRoomNaddr(topBarContext, event)
@@ -223,10 +226,9 @@ internal fun NestFullScreen(
.fillMaxSize() .fillMaxSize()
.padding(padding), .padding(padding),
) { ) {
RoomHeaderStrip( if (summaryExpanded) {
summary = event.summary(), RoomSummaryStrip(summary = event.summary())
listenerCount = presences.size, }
)
// Self-cell tap toggles mic-mute when broadcasting; null // Self-cell tap toggles mic-mute when broadcasting; null
// when not broadcasting so the avatar falls back to the // when not broadcasting so the avatar falls back to the
// default no-op tap (rather than offering a button that // default no-op tap (rather than offering a button that
@@ -245,6 +247,7 @@ internal fun NestFullScreen(
onLongPressParticipant = onLongPressParticipant, onLongPressParticipant = onLongPressParticipant,
myPubkey = myPubkey, myPubkey = myPubkey,
onTapSelf = onTapSelf, onTapSelf = onTapSelf,
listenerCount = presences.size,
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
) )
NestTabRow( NestTabRow(
@@ -366,55 +369,23 @@ internal fun NestFullScreen(
} }
/** /**
* Header strip rendered between the TopAppBar and the stage grid. * Expanded summary strip shown between the TopAppBar and the stage grid
* Carries the LIVE chip + listener count and (when present) a single- * when the user taps the title. The LIVE chip and listener count have
* line ellipsised summary. Lives at the screen level rather than in * moved to the stage card's header row, so this strip now carries only
* the TopAppBar so the chip + count have room to breathe and the * the room description.
* summary's typography matches the body, not the title.
*/ */
@Composable @Composable
private fun RoomHeaderStrip( private fun RoomSummaryStrip(summary: String?) {
summary: String?, if (summary.isNullOrBlank()) return
listenerCount: Int, Text(
) { text = summary,
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) { style = MaterialTheme.typography.bodySmall,
Row( color = MaterialTheme.colorScheme.onSurfaceVariant,
verticalAlignment = Alignment.CenterVertically, modifier =
horizontalArrangement = Arrangement.spacedBy(8.dp), Modifier
) { .fillMaxWidth()
LiveChip() .padding(horizontal = 16.dp, vertical = 4.dp),
Text( )
text = pluralStringResource(R.plurals.nest_listener_count, listenerCount, listenerCount),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (!summary.isNullOrBlank()) {
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
@Composable
private fun LiveChip() {
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
) {
Text(
text = stringRes(R.string.nest_live_chip),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
)
}
} }
/** /**
@@ -491,6 +462,7 @@ private fun NestTopAppBar(
onMinimize: () -> Unit, onMinimize: () -> Unit,
onMenuOpen: () -> Unit, onMenuOpen: () -> Unit,
onMenuDismiss: () -> Unit, onMenuDismiss: () -> Unit,
onTitleClick: () -> Unit,
onShare: () -> Unit, onShare: () -> Unit,
onEdit: () -> Unit, onEdit: () -> Unit,
) { ) {
@@ -500,6 +472,10 @@ private fun NestTopAppBar(
text = title, text = title,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onTitleClick),
) )
}, },
actions = { actions = {
@@ -34,6 +34,7 @@ import androidx.compose.foundation.border
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.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
@@ -57,6 +58,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -137,6 +139,7 @@ internal fun StageGrid(
onLongPressParticipant: ((String) -> Unit)? = null, onLongPressParticipant: ((String) -> Unit)? = null,
myPubkey: String? = null, myPubkey: String? = null,
onTapSelf: (() -> Unit)? = null, onTapSelf: (() -> Unit)? = null,
listenerCount: Int = 0,
) { ) {
// Float currently-speaking members to the top so the listener can // Float currently-speaking members to the top so the listener can
// see who they're hearing without scrolling. sortedBy is stable in // see who they're hearing without scrolling. sortedBy is stable in
@@ -165,12 +168,29 @@ internal fun StageGrid(
vertical = STAGE_CARD_PADDING_VERTICAL, vertical = STAGE_CARD_PADDING_VERTICAL,
), ),
) { ) {
Text( Row(
text = stringRes(R.string.nest_stage), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
style = MaterialTheme.typography.labelMedium, verticalAlignment = Alignment.CenterVertically,
color = MaterialTheme.colorScheme.onSurfaceVariant, ) {
modifier = Modifier.padding(bottom = 8.dp), Text(
) text = stringRes(R.string.nest_stage),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
text =
pluralStringResource(
R.plurals.nest_listener_count,
listenerCount,
listenerCount,
),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 8.dp),
)
LiveChip()
}
if (members.isEmpty()) { if (members.isEmpty()) {
EmptyStageHint() EmptyStageHint()
return@Column return@Column
@@ -203,6 +223,21 @@ internal fun StageGrid(
} }
} }
@Composable
private fun LiveChip() {
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
) {
Text(
text = stringRes(R.string.nest_live_chip),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
)
}
}
/** /**
* Idle "Waiting for speakers…" placeholder for [StageGrid]. Pairs an * Idle "Waiting for speakers…" placeholder for [StageGrid]. Pairs an
* hourglass glyph with the existing copy so the empty state reads as * hourglass glyph with the existing copy so the empty state reads as