@@ -25,6 +25,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
25import androidx.compose.material3.FloatingActionButton
25import androidx.compose.material3.FloatingActionButton
26import androidx.compose.material3.Icon
26import androidx.compose.material3.Icon
27import androidx.compose.material3.IconButton
27import androidx.compose.material3.IconButton
28+import androidx.compose.material3.LinearProgressIndicator
28import androidx.compose.material3.MaterialTheme
29import androidx.compose.material3.MaterialTheme
29import androidx.compose.material3.Scaffold
30import androidx.compose.material3.Scaffold
30import androidx.compose.material3.SnackbarHost
31import androidx.compose.material3.SnackbarHost
@@ -43,10 +44,12 @@ import androidx.compose.ui.Modifier
43import androidx.compose.ui.platform.LocalHapticFeedback
44import androidx.compose.ui.platform.LocalHapticFeedback
44import androidx.compose.ui.text.style.TextOverflow
45import androidx.compose.ui.text.style.TextOverflow
45import androidx.compose.ui.unit.dp
46import androidx.compose.ui.unit.dp
47+import androidx.compose.ui.res.stringResource
46import androidx.lifecycle.compose.collectAsStateWithLifecycle
48import androidx.lifecycle.compose.collectAsStateWithLifecycle
47import androidx.lifecycle.viewmodel.compose.viewModel
49import androidx.lifecycle.viewmodel.compose.viewModel
48import soy.iko.opencode.data.model.ServerProfile
50import soy.iko.opencode.data.model.ServerProfile
49import soy.iko.opencode.di.AppContainer
51import soy.iko.opencode.di.AppContainer
52+import soy.iko.opencode.R
50import soy.iko.opencode.ui.vmFactory
53import soy.iko.opencode.ui.vmFactory
52@OptIn(ExperimentalMaterial3Api::class)
55@OptIn(ExperimentalMaterial3Api::class)
@@ -62,6 +65,7 @@ fun ServerListScreen(
62 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
65 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
63 val error by vm.error.collectAsStateWithLifecycle()
66 val error by vm.error.collectAsStateWithLifecycle()
64 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
67 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
68+ val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
65 val haptics = LocalHapticFeedback.current
69 val haptics = LocalHapticFeedback.current
66 val snackbar = remember { SnackbarHostState() }
70 val snackbar = remember { SnackbarHostState() }
67 var pendingDelete by remember { mutableStateOf<ServerProfile?>(null) }
71 var pendingDelete by remember { mutableStateOf<ServerProfile?>(null) }
@@ -74,21 +78,34 @@ fun ServerListScreen(
81+ // On cold start the container auto-reconnects to the last server; once it lands,
82+ // skip this screen and go straight to the session list. Consumed once so a later
83+ // manual visit to this screen doesn't bounce the user back out.
84+ LaunchedEffect(Unit) {
85+ container.autoConnectDone.collect { succeeded ->
86+ if (succeeded && container.consumeAutoConnect()) onConnected()
78− topBar = { TopAppBar(title = { Text("opencode servers") }) },
91+ topBar = { TopAppBar(title = { Text(stringResource(R.string.servers_title)) }) },
79 snackbarHost = { SnackbarHost(snackbar) },
92 snackbarHost = { SnackbarHost(snackbar) },
80 floatingActionButton = {
93 floatingActionButton = {
81 FloatingActionButton(onClick = onAddProfile) {
94 FloatingActionButton(onClick = onAddProfile) {
82− Icon(Icons.Filled.Add, contentDescription = "Add server")
95+ Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_server))
85 ) { padding ->
98 ) { padding ->
99+ Column(modifier = Modifier.fillMaxSize()) {
100+ if (reconnecting && profiles.isNotEmpty()) {
101+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
86− if (profiles.isEmpty()) {
103+ if (profiles.isEmpty()) {
87− EmptyServers(
104+ EmptyServers(
88− onAdd = onAddProfile,
105+ onAdd = onAddProfile,
89− modifier = Modifier.fillMaxSize().padding(padding),
106+ modifier = Modifier.fillMaxSize().padding(padding),
91− } else {
108+ } else {
92 LazyColumn(
109 LazyColumn(
93 modifier = Modifier.fillMaxSize().padding(padding),
110 modifier = Modifier.fillMaxSize().padding(padding),
94 contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
111 contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
@@ -115,7 +132,7 @@ fun ServerListScreen(
115 overflow = TextOverflow.Ellipsis,
132 overflow = TextOverflow.Ellipsis,
118− profile.baseUrl + if (profile.hasAuth) " • auth" else "",
135+ profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
119 style = MaterialTheme.typography.bodySmall,
136 style = MaterialTheme.typography.bodySmall,
120 color = MaterialTheme.colorScheme.onSurfaceVariant,
137 color = MaterialTheme.colorScheme.onSurfaceVariant,
121 maxLines = 1,
138 maxLines = 1,
@@ -123,7 +140,7 @@ fun ServerListScreen(
124 if (isActive) {
141 if (isActive) {
126− "Connected",
143+ stringResource(R.string.connected),
127 style = MaterialTheme.typography.labelSmall,
144 style = MaterialTheme.typography.labelSmall,
128 color = MaterialTheme.colorScheme.primary,
145 color = MaterialTheme.colorScheme.primary,
@@ -132,7 +149,7 @@ fun ServerListScreen(
132 if (isActive) {
149 if (isActive) {
134 Icons.Filled.CheckCircle,
151 Icons.Filled.CheckCircle,
135− contentDescription = "Connected",
152+ contentDescription = stringResource(R.string.connected),
136 tint = MaterialTheme.colorScheme.primary,
153 tint = MaterialTheme.colorScheme.primary,
137 modifier = Modifier.size(22.dp),
154 modifier = Modifier.size(22.dp),
@@ -140,33 +157,34 @@ fun ServerListScreen(
140 CircularProgressIndicator(modifier = Modifier.size(24.dp))
157 CircularProgressIndicator(modifier = Modifier.size(24.dp))
142 IconButton(onClick = { onEditProfile(profile.id) }) {
159 IconButton(onClick = { onEditProfile(profile.id) }) {
143− Icon(Icons.Filled.Edit, contentDescription = "Edit")
160+ Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
145 IconButton(onClick = { pendingDelete = profile }) {
162 IconButton(onClick = { pendingDelete = profile }) {
146− Icon(Icons.Filled.Delete, contentDescription = "Delete")
163+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
156 pendingDelete?.let { profile ->
174 pendingDelete?.let { profile ->
157 AlertDialog(
175 AlertDialog(
158 onDismissRequest = { pendingDelete = null },
176 onDismissRequest = { pendingDelete = null },
159− title = { Text("Remove server?") },
177+ title = { Text(stringResource(R.string.remove_server_title)) },
160− text = { Text("“${profile.displayLabel}” will be removed from your saved servers.") },
178+ text = { Text(stringResource(R.string.remove_server_text, profile.displayLabel)) },
161 confirmButton = {
179 confirmButton = {
162 TextButton(onClick = {
180 TextButton(onClick = {
163 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
181 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
164 pendingDelete = null
182 pendingDelete = null
165 vm.delete(profile)
183 vm.delete(profile)
166− }) { Text("Remove", color = MaterialTheme.colorScheme.error) }
184+ }) { Text(stringResource(R.string.remove), color = MaterialTheme.colorScheme.error) }
168 dismissButton = {
186 dismissButton = {
169− TextButton(onClick = { pendingDelete = null }) { Text("Cancel") }
187+ TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.cancel)) }
@@ -186,17 +204,17 @@ private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
186 tint = MaterialTheme.colorScheme.onSurfaceVariant,
204 tint = MaterialTheme.colorScheme.onSurfaceVariant,
188 Spacer(Modifier.size(16.dp))
206 Spacer(Modifier.size(16.dp))
189− Text("No servers yet", style = MaterialTheme.typography.titleMedium)
207+ Text(stringResource(R.string.no_servers_yet), style = MaterialTheme.typography.titleMedium)
190 Spacer(Modifier.size(8.dp))
208 Spacer(Modifier.size(8.dp))
192− "Add an opencode serve endpoint to connect.",
210+ stringResource(R.string.no_servers_hint),
193 style = MaterialTheme.typography.bodyMedium,
211 style = MaterialTheme.typography.bodyMedium,
194 color = MaterialTheme.colorScheme.onSurfaceVariant,
212 color = MaterialTheme.colorScheme.onSurfaceVariant,
196 Spacer(Modifier.size(20.dp))
214 Spacer(Modifier.size(20.dp))
197 TextButton(onClick = onAdd) {
215 TextButton(onClick = onAdd) {
198 Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
216 Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
199− Text(" Add server")
217+ Text(" " + stringResource(R.string.add_server))