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:
@@ -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"
|
||||
|
||||
@@ -699,7 +699,8 @@ fun MantraNavHost(
|
||||
)
|
||||
},
|
||||
nostrRepository = databaseNostrRepository,
|
||||
chatRepository = databaseChatRepository
|
||||
chatRepository = databaseChatRepository,
|
||||
frostSigningRepository = databaseFrostSigningRepository,
|
||||
)
|
||||
}
|
||||
composable<BlankRoute> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user