git.iko.soy/opencode-android
on d29e57b help sign in
127 lines 5.0 kB history raw
1package soy.iko.opencode.ui.session
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.widthIn
10import androidx.activity.compose.BackHandler
11import androidx.compose.material.icons.Icons
12import androidx.compose.material.icons.automirrored.filled.Chat
13import androidx.compose.material3.Icon
14import androidx.compose.material3.MaterialTheme
15import androidx.compose.material3.Text
16import androidx.compose.runtime.Composable
17import androidx.compose.runtime.LaunchedEffect
18import androidx.compose.runtime.getValue
19import androidx.compose.runtime.mutableStateOf
20import androidx.compose.runtime.saveable.rememberSaveable
21import androidx.compose.runtime.setValue
22import androidx.compose.ui.Alignment
23import androidx.compose.ui.Modifier
24import androidx.compose.ui.res.stringResource
25import androidx.compose.ui.unit.dp
26import androidx.lifecycle.compose.collectAsStateWithLifecycle
27import soy.iko.opencode.R
28import soy.iko.opencode.data.network.NetworkConfig
29import soy.iko.opencode.di.AppContainer
30import soy.iko.opencode.ui.chat.ChatScreen
32/**
33 * Master–detail layout for wide screens (≥ 840dp, e.g. tablets / unfolded foldables):
34 * the session list on the left, the selected conversation on the right. On compact
35 * widths the app uses the single-pane [androidx.navigation] back stack instead.
36 *
37 * Each side hosts an existing screen (which carries its own Scaffold + app bar), so the
38 * two panes are self-contained. Selecting a session updates the detail pane rather than
39 * pushing a destination; the chat [onBack] clears the selection. The chat composition is
40 * keyed by session id so each conversation gets its own ViewModel.
41 */
42@Composable
43fun TwoPaneSessionChat(
44 container: AppContainer,
45 onOpenFiles: () -> Unit,
46 onOpenSettings: () -> Unit,
47 onDisconnect: () -> Unit,
48 modifier: Modifier = Modifier,
49) {
50 var selected by rememberSaveable { mutableStateOf<String?>(null) }
51 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
52 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
54 // A notification tap / deep link requests a session: open it in the detail pane.
55 LaunchedEffect(pendingOpenSession) {
56 pendingOpenSession?.let {
57 selected = it
58 container.consumePendingOpenSession()
59 }
60 }
62 // Inject a pending share into the currently selected session's draft (if any).
63 // Unlike single-pane mode where the session list is navigated to specifically for
64 // the share, in two-pane mode the list is always visible — so we inject into the
65 // session the user is already viewing. If no session is selected, the share is
66 // deferred until the user picks one (the LaunchedEffect re-fires when `selected`
67 // changes to non-null).
68 LaunchedEffect(pendingShare, selected) {
69 val target = selected
70 if (pendingShare != null && target != null) {
71 container.consumePendingShare()?.let { container.draftStore.set(target, it) }
72 }
73 }
75 Row(modifier = modifier.fillMaxSize()) {
76 Box(
77 modifier = Modifier
78 .weight(NetworkConfig.twoPaneLeftWeight)
79 .widthIn(max = 460.dp),
80 ) {
81 SessionListScreen(
82 container = container,
83 onOpenSession = { id ->
84 selected = id
85 },
86 onDisconnect = onDisconnect,
87 onOpenFiles = onOpenFiles,
88 onOpenSettings = onOpenSettings,
89 )
90 }
92 Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
93 val sessionId = selected
94 if (sessionId == null) {
95 EmptyDetail()
96 } else {
97 BackHandler { selected = null }
98 ChatScreen(
99 container = container,
100 sessionId = sessionId,
101 onBack = { selected = null },
102 )
103 }
104 }
105 }
108@Composable
109private fun EmptyDetail() {
110 Column(
111 modifier = Modifier.fillMaxSize().padding(32.dp),
112 verticalArrangement = Arrangement.Center,
113 horizontalAlignment = Alignment.CenterHorizontally,
114 ) {
115 Icon(
116 Icons.AutoMirrored.Filled.Chat,
117 contentDescription = null,
118 tint = MaterialTheme.colorScheme.outline,
119 )
120 Text(
121 stringResource(R.string.empty_detail_pane),
122 style = MaterialTheme.typography.bodyLarge,
123 color = MaterialTheme.colorScheme.onSurfaceVariant,
124 modifier = Modifier.padding(top = 12.dp),
125 )
126 }