git.iko.soy/opencode-android
on fb38d12 help sign in
165 lines 7.4 kB history raw
1package soy.iko.opencode.ui
3import androidx.compose.foundation.layout.BoxWithConstraints
4import androidx.compose.runtime.Composable
5import androidx.compose.runtime.LaunchedEffect
6import androidx.compose.runtime.getValue
7import androidx.compose.runtime.rememberCoroutineScope
8import androidx.compose.ui.unit.dp
9import androidx.navigation.NavType
10import androidx.navigation.compose.NavHost
11import androidx.navigation.compose.composable
12import androidx.navigation.compose.rememberNavController
13import androidx.navigation.navArgument
14import androidx.lifecycle.compose.collectAsStateWithLifecycle
15import kotlinx.coroutines.launch
16import soy.iko.opencode.di.AppContainer
17import soy.iko.opencode.data.network.NetworkConfig
18import soy.iko.opencode.ui.chat.ChatScreen
19import soy.iko.opencode.ui.file.FileBrowserScreen
20import soy.iko.opencode.ui.file.FileViewScreen
21import soy.iko.opencode.ui.server.ServerEditScreen
22import soy.iko.opencode.ui.server.ServerListScreen
23import soy.iko.opencode.ui.session.SessionListScreen
24import soy.iko.opencode.ui.session.TwoPaneSessionChat
25import soy.iko.opencode.ui.settings.DiagnosticsScreen
26import soy.iko.opencode.ui.settings.SettingsScreen
27import soy.iko.opencode.util.runCatchingCancellable
29@Composable
30fun OpencodeApp(container: AppContainer) {
31 val navController = rememberNavController()
32 val scope = rememberCoroutineScope()
33 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
34 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
35 val connection by container.activeConnection.collectAsStateWithLifecycle()
37 // When text is shared into the app, surface the session list so the user can pick
38 // (or create) a conversation to drop it into. The chosen session's draft is set
39 // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
40 // before auto-reconnect completes is retried once the connection is established.
41 LaunchedEffect(pendingShare, connection) {
42 if (pendingShare == null) return@LaunchedEffect
43 if (connection == null) return@LaunchedEffect
44 if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
45 navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
46 }
47 }
49 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
50 // the open conversation side by side instead of a single-pane back stack.
51 BoxWithConstraints {
52 val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp && connection != null
54 // Open a session requested by a notification tap or deep link, once connected.
55 // In two-pane mode the detail pane consumes the request instead of navigating.
56 LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
57 val id = pendingOpenSession ?: return@LaunchedEffect
58 if (connection == null || isTwoPane) return@LaunchedEffect
59 container.consumePendingOpenSession()
60 navController.navigate(Routes.chat(id)) { launchSingleTop = true }
61 }
63 NavHost(navController = navController, startDestination = Routes.SERVERS) {
65 composable(Routes.SERVERS) {
66 ServerListScreen(
67 container = container,
68 onConnected = { navController.navigate(Routes.SESSIONS) },
69 onAddProfile = { navController.navigate(Routes.serverEdit()) },
70 onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
71 )
72 }
74 composable(
75 route = "${Routes.SERVER_EDIT}?id={id}",
76 arguments = listOf(navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null }),
77 ) { entry ->
78 ServerEditScreen(
79 container = container,
80 profileId = entry.arguments?.getString("id"),
81 onDone = { navController.popBackStack() },
82 )
83 }
85 composable(Routes.SESSIONS) {
86 if (isTwoPane) {
87 TwoPaneSessionChat(
88 container = container,
89 onOpenFiles = { navController.navigate(Routes.FILES) },
90 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
91 onDisconnect = {
92 scope.launch { runCatchingCancellable { container.disconnect() } }
93 navController.popBackStack(Routes.SERVERS, inclusive = false)
94 },
95 )
96 } else {
97 SessionListScreen(
98 container = container,
99 onOpenSession = { id ->
100 // Set the draft synchronously in-memory before navigating so the
101 // ChatScreen sees it on first composition. The async persistence
102 // to disk happens in the background via draftStore.set.
103 container.consumePendingShare()?.let { shareText ->
104 container.draftStore.setImmediate(id, shareText)
105 scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
106 }
107 navController.navigate(Routes.chat(id))
108 },
109 onDisconnect = {
110 scope.launch { runCatchingCancellable { container.disconnect() } }
111 navController.popBackStack(Routes.SERVERS, inclusive = false)
112 },
113 onOpenFiles = { navController.navigate(Routes.FILES) },
114 onOpenSettings = { navController.navigate(Routes.SETTINGS) },
115 )
116 }
117 }
119 composable(
120 route = "${Routes.CHAT}/{sessionId}",
121 arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
122 ) { entry ->
123 ChatScreen(
124 container = container,
125 sessionId = entry.arguments?.getString("sessionId").orEmpty(),
126 onBack = { navController.popBackStack() },
127 )
128 }
130 composable(Routes.FILES) {
131 FileBrowserScreen(
132 container = container,
133 onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
134 onBack = { navController.popBackStack() },
135 )
136 }
138 composable(
139 route = "${Routes.FILE_VIEW}?path={path}",
140 arguments = listOf(navArgument("path") { type = NavType.StringType; defaultValue = "" }),
141 ) { entry ->
142 FileViewScreen(
143 container = container,
144 path = entry.arguments?.getString("path").orEmpty(),
145 onBack = { navController.popBackStack() },
146 )
147 }
149 composable(Routes.SETTINGS) {
150 SettingsScreen(
151 container = container,
152 onBack = { navController.popBackStack() },
153 onManageServers = {
154 navController.popBackStack(Routes.SERVERS, inclusive = false)
155 },
156 onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
157 )
158 }
160 composable(Routes.DIAGNOSTICS) {
161 DiagnosticsScreen(onBack = { navController.popBackStack() })
162 }
163 }
164 }