feat: put the chat list beside the conversation, from the expanded breakpoint up

Phase 6, step 4, chat first as the plan asks. On a window 840dp or wider the home
screen is now the room list at a fixed width and the selected conversation
filling the rest; on anything narrower it is exactly what it was.

**Below expanded is not caution, it is the spec.** The breakpoints page says not
to put two dense panes in a medium window, and `calculatePaneScaffoldDirective`
in `material3-adaptive` says the same thing in code -- `maxHorizontalPartitions
= 1` for compact and medium alike. A chat transcript is precisely the dense
content that rule is about.

It is also what this app can support. `ChatRoomMessagingRoute` is navigated to
from **eleven** places -- a DKG ritual finishing, room-type selection, the npub
dialog, a profile -- so the conversation has to remain a pushed destination
whatever the window is doing. The list pane is a second way to reach it on a wide
window, not a replacement for the first.

**Why not `ListDetailPaneScaffold`.** The dependency is available and resolves
for every target; the scaffold was not used, and the reason is the paragraph
above. It earns its API surface -- a navigator, a destination history, an
`AnimatedPane` per pane, three experimental opt-ins -- by owning the single-pane
case as well: showing the detail *instead of* the list on a phone and animating
between them. This app cannot hand it that, so it would sit permanently in its
two-pane state and amount to a `Row` with more words and a history nothing reads.
What it does have that is worth keeping is its numbers, and `Panes.kt` takes
them: 360dp of list at expanded, 412dp from large upward, 24dp between. A
hand-built pair measures the same as the scaffold would.

**Three smaller decisions.**

The floating action button moves into the list pane when there are two. The
`Scaffold`'s slot is the bottom-right of the *window*, which with two panes is on
top of the transcript's send button; M3 puts a list-detail layout's primary
action in the list pane. It is one composable used from both branches so the two
cannot drift.

`readableContent()` comes off the pair. Capping two panes together to one
column's measure is the opposite of what a second pane is for -- each pane holds
its own content instead, and the conversation already did.

The detail pane says "Pick a conversation to read it here" rather than being an
unexplained empty half of a window, and the conversation is keyed on the room so
switching rebuilds its view models rather than feeding a new id to ones already
subscribed to another room's relays.

**Measured in real windows of the widths the phase names.** 400 and 700 are one
pane; 1000 splits with a 360dp list; 1400 splits with a 412dp list. The
repositories are the no-op ones with the two reads this screen makes delegated to
a fixed answer -- Kotlin's interface delegation makes that ten lines rather than
a reimplementation of two large interfaces. Five more unit tests pin the widths
against the directive's, including that the detail pane still clears a
40-character line in the narrowest window that allows two of them.

635 jvm tests green; android compiles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-08 08:01:16 +02:00
parent 714354ae9a
commit 3f78eef1e8
7 changed files with 394 additions and 21 deletions

View File

@@ -1,13 +1,19 @@
package press.mantra.compose.ui.composable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Icon
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
@@ -26,8 +32,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -57,7 +65,14 @@ import mantra.composeapp.generated.resources.new_chat
import press.mantra.compose.ui.composable.widgets.ErrorState
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import androidx.compose.ui.Alignment
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute
import press.mantra.compose.ui.composable.widgets.EmptyState
import press.mantra.compose.ui.theme.breakpoint
import press.mantra.compose.ui.theme.listPaneWidthFor
import press.mantra.compose.ui.theme.readableContent
import mantra.composeapp.generated.resources.pick_a_conversation_to_read_it_here
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -68,13 +83,39 @@ fun HomeScreen(
onNavigateToDirectMessageDetail: (Route) -> Unit,
onNavigateToChatRoomCreation: () -> Unit,
nostrRepository: NostrRepository,
chatRepository: ChatRepository
chatRepository: ChatRepository,
// Only the detail pane uses this, and only from the expanded breakpoint up. It is a
// required parameter rather than a nullable one because a home screen that silently
// loses its detail pane on a desktop is a worse failure than a compile error.
frostSigningRepository: FrostSigningRepository,
) {
val sheetState = rememberModalBottomSheetState()
val scope = rememberCoroutineScope()
var showBottomSheet by remember { mutableStateOf(false) }
val openNpubDialog = remember { mutableStateOf(false) }
// Null below the expanded breakpoint, which is where the room list is the whole screen
// and tapping a room navigates, exactly as it did before panes existed.
val listPaneWidth = listPaneWidthFor(MaterialTheme.breakpoint)
// The room the detail pane is showing. Two saveable strings rather than the route
// object, because that is all the route carries and both survive a process death that
// a `@Serializable` route would need a Saver to survive.
var selectedChatRoomId by rememberSaveable { mutableStateOf<String?>(null) }
var selectedRelayHint by rememberSaveable { mutableStateOf<String?>(null) }
// Tapping a room means two different things at two widths, and the list does not need
// to know which: it hands over a `ChatRoomMessagingRoute` either way, and this decides
// whether that is a destination to push or a selection to make.
val onOpenChatRoom: (Route) -> Unit = { route ->
if (listPaneWidth != null && route is ChatRoomMessagingRoute) {
selectedChatRoomId = route.chatRoomId
selectedRelayHint = route.relayHint
} else {
onNavigateToDirectMessageDetail(route)
}
}
val homeScreenViewModel: HomeViewModel = viewModel(
factory = HomeViewModel.factory(
activeUserPublicKey = activeUserPublicKey,
@@ -126,24 +167,16 @@ fun HomeScreen(
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = {
showBottomSheet = true
},
icon = {
Icon(
Icons.Default.Add,
contentDescription = "New chat"
)
},
text = {
Text(stringResource(Res.string.new_chat))
}
)
// Only while there is one pane. With two, the scaffold's FAB slot is
// the bottom-right of the *window*, which is on top of the
// transcript's send button; M3 puts a list-detail layout's primary
// action in the list pane, and so does the branch below.
if (listPaneWidth == null) NewChatButton { showBottomSheet = true }
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
@Composable
fun ChatRoomListPane(modifier: Modifier) = Column(
modifier = modifier
) {
PrimaryScrollableTabRow(
modifier = Modifier.padding(MaterialTheme.spacing.space125),
@@ -189,7 +222,7 @@ fun HomeScreen(
key(true) {
chatRoomListViewModel.RenderFeed(
onNavigateToDirectMessageDetail = onNavigateToDirectMessageDetail
onNavigateToDirectMessageDetail = onOpenChatRoom
)
}
@@ -202,6 +235,72 @@ fun HomeScreen(
}
}
if (listPaneWidth == null) {
// Compact and medium: the list is the screen, and a room is a route.
// Byte for byte the layout this screen has always had.
ChatRoomListPane(
Modifier.padding(innerPadding).readableContent().fillMaxSize()
)
} else {
// Expanded and up: the list keeps a fixed width and the room fills the
// rest. No `readableContent()` on the pair -- capping the two panes
// together to one column's measure is the opposite of what a second
// pane is for. Each pane holds its own content to a measure instead,
// and `ChatRoomMessagingScreen` already does.
Row(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
Box(
modifier = Modifier
.width(listPaneWidth)
.fillMaxHeight()
// The pane's width is the one thing about this layout that
// is a number rather than a rule, and the only way to check
// a number is to measure it in a composition.
.testTag(ChatListPaneTag)
) {
ChatRoomListPane(Modifier.fillMaxSize())
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(MaterialTheme.spacing.containerPadding)
) {
NewChatButton { showBottomSheet = true }
}
}
Spacer(modifier = Modifier.width(MaterialTheme.spacing.paneGap))
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
val chatRoomId = selectedChatRoomId
if (chatRoomId == null) {
EmptyState(
message = stringResource(Res.string.pick_a_conversation_to_read_it_here)
)
} else {
// Keyed on the room, so switching rooms rebuilds the
// screen's view models rather than feeding a new id to
// ones already subscribed to another room's relay.
key(chatRoomId) {
ChatRoomMessagingScreen(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
relayHint = selectedRelayHint,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository,
// "Replace what is on screen" is a route push on a
// phone and a change of selection here. It fires when
// a conversation that did not exist yet has just been
// created and has an id at last.
onNavigateToRouteAndPopUpInclusive = onOpenChatRoom,
onNavigateToRoute = onNavigateToRoute,
)
}
}
}
}
}
if (showBottomSheet) {
NewChatBottomSheetDialog(
scope = scope,
@@ -276,8 +375,35 @@ It has survived not only five centuries, but also the leap into electronic types
onNavigateToChatRoomCreation = {},
onNavigateToDirectMessageDetail = {},
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
)
}
}
}
}
/**
* The one action the chat list offers, in whichever slot the layout has for it.
*
* Extracted only so that the two branches above cannot drift: on one pane it is the
* `Scaffold`'s floating action button, on two it sits in the bottom corner of the list
* pane, and a "new chat" that reads differently depending on window width would be a
* strange thing to discover.
*/
@Composable
private fun NewChatButton(onClick: () -> Unit) {
ExtendedFloatingActionButton(
onClick = onClick,
icon = {
Icon(
Icons.Default.Add,
// Decorative: the button's own text says "New chat" beside it.
contentDescription = null,
)
},
text = { Text(stringResource(Res.string.new_chat)) },
)
}
/** Addresses the chat list pane from a layout test. See `ChatPaneLayoutJvmTest`. */
const val ChatListPaneTag = "chat-list-pane"

View File

@@ -699,7 +699,8 @@ fun MantraNavHost(
)
},
nostrRepository = databaseNostrRepository,
chatRepository = databaseChatRepository
chatRepository = databaseChatRepository,
frostSigningRepository = databaseFrostSigningRepository,
)
}
composable<BlankRoute> {

View File

@@ -0,0 +1,30 @@
package press.mantra.compose.ui.theme
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* How wide a list pane should be at [breakpoint], or `null` where M3 asks for one pane.
*
* **`null` below expanded is the spec, not caution.** The breakpoints page says not to put
* two dense panes in a medium window, and `calculatePaneScaffoldDirective` in
* `material3-adaptive` says the same thing in code: `maxHorizontalPartitions = 1` for both
* compact and medium. A chat transcript is exactly the dense content that rule is about.
*
* The two widths are that same function's, so a hand-built pair of panes measures the same
* as a `ListDetailPaneScaffold` would: `DefaultPreferredWidth` at expanded, and
* `DefaultPreferredWidthXL` from large upward, where the directive also allows a third
* partition this app has no content for.
*
* **Why not the scaffold itself.** `ListDetailPaneScaffold` earns its API surface by
* owning the single-pane case too -- showing the detail *instead of* the list on a phone,
* and animating between them. This app cannot hand it that: the chat room is a navigation
* destination reached from eleven places, so on a compact window the detail has to stay a
* pushed route. A scaffold permanently in its two-pane state would be a `Row` with more
* words, and a navigator whose history nothing reads.
*/
fun listPaneWidthFor(breakpoint: Breakpoint): Dp? = when (breakpoint) {
Breakpoint.Compact, Breakpoint.Medium -> null
Breakpoint.Expanded -> 360.dp
Breakpoint.Large, Breakpoint.ExtraLarge -> 412.dp
}

View File

@@ -98,6 +98,12 @@ data class Spacing(
/** Between adjacent touch targets, which M3 asks to be at least 8dp apart. */
val targetGap: Dp = space100,
/**
* Between two panes. M3's own `PaneScaffoldDirective` uses 24dp at every breakpoint
* that has a second pane, which is why this does not vary with the window either.
*/
val paneGap: Dp = space300,
)
/**