on master help sign in
Improve chat and connect screen UX with loading states, session picker sheet, and message timestamps
commit9bab537556ce1a4efaed848c3bca6c484c679e6d authoriko <mail@iko.soy> dateTue 7 Jul 2026, 21:51 (3 months ago) parent63d5843
5 files changed+341−73
unifiedsplit
app/src/main/java/soy/iko/crush/MainActivity.kt+2 app/src/main/java/soy/iko/crush/data/CrushRepository.kt+10 app/src/main/java/soy/iko/crush/ui/ChatViewModel.kt+16 app/src/main/java/soy/iko/crush/ui/screens/ChatScreen.kt+246−63 app/src/main/java/soy/iko/crush/ui/screens/ConnectScreen.kt+67−10
app/src/main/java/soy/iko/crush/MainActivity.kt+2
@@ -31,12 +31,14 @@ class MainActivity : ComponentActivity() {
3131 onNewSession = vm::newSession,
3232 onPickSession = vm::openSession,
3333 onGrantPermission = vm::grantPermission,
34+ onDisconnect = vm::disconnect,
3435 )
3536 } else {
3637 ConnectScreen(
3738 defaultHost = state.host,
3839 defaultWorkspace = state.workspacePath,
3940 error = state.error,
41+ isConnecting = state.isConnecting,
4042 onConnect = vm::connect,
4143 )
4244 }
app/src/main/java/soy/iko/crush/data/CrushRepository.kt+10
@@ -70,6 +70,16 @@ class CrushRepository(
7070 return true
7171 }
7272
73+ fun disconnect() {
74+ _connection.value = ConnectionState.Disconnected
75+ workspaceId = null
76+ sessionId = null
77+ _messages.value = emptyList()
78+ _sessions.value = emptyList()
79+ _permissions.value = emptyList()
80+ _workspaces.value = emptyList()
81+ }
82+
7383 suspend fun refreshWorkspaces() {
7484 _workspaces.value = runCatching { api.listWorkspaces() }.getOrDefault(emptyList())
7585 }
app/src/main/java/soy/iko/crush/ui/ChatViewModel.kt+16
@@ -31,6 +31,7 @@ data class ChatUiState(
3131 val messages: List<Message> = emptyList(),
3232 val pendingPermissions: List<PermissionRequest> = emptyList(),
3333 val isBusy: Boolean = false,
34+ val isConnecting: Boolean = false,
3435 val error: String? = null,
3536)
3637
@@ -41,6 +42,7 @@ class ChatViewModel(
4142
4243 private val _error = MutableStateFlow<String?>(null)
4344 private val _busy = MutableStateFlow(false)
45+ private val _connecting = MutableStateFlow(false)
4446
4547 private var sseJob: Job? = null
4648
@@ -55,6 +57,7 @@ class ChatViewModel(
5557 repo.permissions as Flow<Any>,
5658 _busy as Flow<Any>,
5759 _error as Flow<Any>,
60+ _connecting as Flow<Any>,
5861 ) { values ->
5962 ChatUiState(
6063 connection = values[0] as ConnectionState,
@@ -67,6 +70,7 @@ class ChatViewModel(
6770 pendingPermissions = values[5] as List<PermissionRequest>,
6871 isBusy = values[6] as Boolean,
6972 error = values[7] as String?,
73+ isConnecting = values[8] as Boolean,
7074 )
7175 }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ChatUiState())
7276
@@ -75,22 +79,34 @@ class ChatViewModel(
7579 fun connect(host: String, workspacePath: String) {
7680 viewModelScope.launch(Dispatchers.IO) {
7781 _error.value = null
82+ _connecting.value = true
7883 settings.setHost(host)
7984 settings.setWorkspace(workspacePath)
8085 val ok = repo.connect(host)
8186 if (!ok) {
8287 _error.value = "Could not reach crush server at $host"
88+ _connecting.value = false
8389 return@launch
8490 }
8591 val ws = repo.openWorkspace(workspacePath)
8692 if (ws == null) {
8793 _error.value = "Could not open workspace '$workspacePath'"
94+ _connecting.value = false
8895 return@launch
8996 }
97+ _connecting.value = false
9098 startEventStream()
9199 }
92100 }
93101
102+ fun disconnect() {
103+ sseJob?.cancel()
104+ repo.disconnect()
105+ _busy.value = false
106+ _connecting.value = false
107+ _error.value = null
108+ }
109+
94110 fun newSession() {
95111 viewModelScope.launch(Dispatchers.IO) {
96112 _error.value = null
app/src/main/java/soy/iko/crush/ui/screens/ChatScreen.kt+246−63
@@ -1,6 +1,7 @@
11package soy.iko.crush.ui.screens
22
33import androidx.compose.foundation.background
4+import androidx.compose.foundation.clickable
45import androidx.compose.foundation.layout.Arrangement
56import androidx.compose.foundation.layout.Box
67import androidx.compose.foundation.layout.Column
@@ -15,28 +16,33 @@ import androidx.compose.foundation.layout.widthIn
1516import androidx.compose.foundation.lazy.LazyColumn
1617import androidx.compose.foundation.lazy.items
1718import androidx.compose.foundation.lazy.rememberLazyListState
19+import androidx.compose.foundation.shape.CircleShape
1820import androidx.compose.foundation.shape.RoundedCornerShape
21+import androidx.compose.foundation.text.KeyboardActions
22+import androidx.compose.foundation.text.KeyboardOptions
1923import androidx.compose.material.icons.Icons
2024import androidx.compose.material.icons.automirrored.filled.Send
2125import androidx.compose.material.icons.filled.Add
26+import androidx.compose.material.icons.filled.MoreVert
2227import androidx.compose.material.icons.filled.Stop
2328import androidx.compose.material3.AlertDialog
2429import androidx.compose.material3.Button
25−import androidx.compose.material3.Card
26−import androidx.compose.material3.CardDefaults
2730import androidx.compose.material3.CircularProgressIndicator
31+import androidx.compose.material3.DropdownMenu
32+import androidx.compose.material3.DropdownMenuItem
2833import androidx.compose.material3.ExperimentalMaterial3Api
29−import androidx.compose.material3.FloatingActionButton
3034import androidx.compose.material3.HorizontalDivider
3135import androidx.compose.material3.Icon
3236import androidx.compose.material3.IconButton
3337import androidx.compose.material3.MaterialTheme
38+import androidx.compose.material3.ModalBottomSheet
3439import androidx.compose.material3.OutlinedTextField
3540import androidx.compose.material3.Scaffold
3641import androidx.compose.material3.Surface
3742import androidx.compose.material3.Text
3843import androidx.compose.material3.TextButton
3944import androidx.compose.material3.TopAppBar
45+import androidx.compose.material3.rememberModalBottomSheetState
4046import androidx.compose.runtime.Composable
4147import androidx.compose.runtime.LaunchedEffect
4248import androidx.compose.runtime.getValue
@@ -46,12 +52,19 @@ import androidx.compose.runtime.setValue
4652import androidx.compose.ui.Alignment
4753import androidx.compose.ui.Modifier
4854import androidx.compose.ui.draw.clip
55+import androidx.compose.ui.platform.LocalContext
4956import androidx.compose.ui.text.font.FontFamily
5057import androidx.compose.ui.text.font.FontWeight
58+import androidx.compose.ui.text.input.ImeAction
5159import androidx.compose.ui.unit.dp
60+import soy.iko.crush.proto.Message
5261import soy.iko.crush.proto.MessageRole
62+import soy.iko.crush.proto.PermissionRequest
63+import soy.iko.crush.proto.Session
5364import soy.iko.crush.ui.ChatUiState
5465import soy.iko.crush.ui.MessageRenderer
66+import java.text.DateFormat
67+import java.util.Date
5568
5669@OptIn(ExperimentalMaterial3Api::class)
5770@Composable
@@ -61,13 +74,14 @@ fun ChatScreen(
6174 onCancel: () -> Unit,
6275 onNewSession: () -> Unit,
6376 onPickSession: (String) -> Unit,
64− onGrantPermission: (soy.iko.crush.proto.PermissionRequest, Boolean) -> Unit,
77+ onGrantPermission: (PermissionRequest, Boolean) -> Unit,
78+ onDisconnect: () -> Unit,
6579) {
6680 var input by remember { mutableStateOf("") }
6781 var showSessionPicker by remember { mutableStateOf(false) }
82+ var showOverflow by remember { mutableStateOf(false) }
6883 val listState = rememberLazyListState()
6984
70− // Auto-scroll to bottom when new messages arrive.
7185 LaunchedEffect(state.messages.size) {
7286 if (state.messages.isNotEmpty()) {
7387 listState.animateScrollToItem(state.messages.lastIndex)
@@ -79,8 +93,12 @@ fun ChatScreen(
7993 TopAppBar(
8094 title = {
8195 Column {
82− Text("Crush", fontWeight = FontWeight.Bold)
83− val subtitle = state.currentSessionId?.let { "session $it" } ?: "no session"
96+ Row(verticalAlignment = Alignment.CenterVertically) {
97+ Text("Crush", fontWeight = FontWeight.Bold)
98+ Spacer(Modifier.size(6.dp))
99+ StatusDot(state)
100+ }
101+ val subtitle = state.currentSessionId?.let { "session ${it.take(8)}" } ?: "no session"
84102 Text(
85103 subtitle,
86104 style = MaterialTheme.typography.labelSmall,
@@ -101,6 +119,30 @@ fun ChatScreen(
101119 IconButton(onClick = { showSessionPicker = true }) {
102120 Icon(Icons.Filled.Add, contentDescription = "Sessions")
103121 }
122+ Box {
123+ IconButton(onClick = { showOverflow = true }) {
124+ Icon(Icons.Filled.MoreVert, contentDescription = "More")
125+ }
126+ DropdownMenu(
127+ expanded = showOverflow,
128+ onDismissRequest = { showOverflow = false },
129+ ) {
130+ DropdownMenuItem(
131+ text = { Text("Switch session") },
132+ onClick = {
133+ showOverflow = false
134+ showSessionPicker = true
135+ },
136+ )
137+ DropdownMenuItem(
138+ text = { Text("Disconnect") },
139+ onClick = {
140+ showOverflow = false
141+ onDisconnect()
142+ },
143+ )
144+ }
145+ }
104146 },
105147 )
106148 },
@@ -126,20 +168,28 @@ fun ChatScreen(
126168 if (state.error != null) {
127169 ErrorBanner(state.error)
128170 }
129− LazyColumn(
130− state = listState,
131− modifier = Modifier
132− .weight(1f)
133− .fillMaxWidth()
134− .padding(horizontal = 12.dp),
135− verticalArrangement = Arrangement.spacedBy(8.dp),
136− contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp),
137− ) {
138− items(state.messages, key = { it.id }) { msg ->
139− MessageBubble(msg)
140− }
141− if (state.isBusy && state.messages.isEmpty()) {
142− item { TypingIndicator() }
171+ if (state.messages.isEmpty() && !state.isBusy) {
172+ EmptyConversation(
173+ modifier = Modifier
174+ .weight(1f)
175+ .fillMaxWidth(),
176+ )
177+ } else {
178+ LazyColumn(
179+ state = listState,
180+ modifier = Modifier
181+ .weight(1f)
182+ .fillMaxWidth()
183+ .padding(horizontal = 12.dp),
184+ verticalArrangement = Arrangement.spacedBy(8.dp),
185+ contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp),
186+ ) {
187+ items(state.messages, key = { it.id }) { msg ->
188+ MessageBubble(msg)
189+ }
190+ if (state.isBusy && state.messages.isEmpty()) {
191+ item { TypingIndicator() }
192+ }
143193 }
144194 }
145195 PermissionQueue(
@@ -167,27 +217,74 @@ fun ChatScreen(
167217}
168218
169219@Composable
170−private fun MessageBubble(message: soy.iko.crush.proto.Message) {
220+private fun StatusDot(state: ChatUiState) {
221+ val color = when {
222+ state.isBusy -> MaterialTheme.colorScheme.tertiary
223+ state.currentSessionId != null -> MaterialTheme.colorScheme.primary
224+ else -> MaterialTheme.colorScheme.outline
225+ }
226+ Box(
227+ modifier = Modifier
228+ .size(8.dp)
229+ .clip(CircleShape)
230+ .background(color),
231+ )
232+}
233+
234+@Composable
235+private fun MessageBubble(message: Message) {
171236 val isUser = message.role == MessageRole.user
172237 val bg = if (isUser) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
173238 else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
174239 val alignment = if (isUser) Alignment.End else Alignment.Start
240+ val context = LocalContext.current
175241
176242 Column(
177243 modifier = Modifier.fillMaxWidth(),
178244 horizontalAlignment = alignment,
179245 ) {
180− Text(
181− MessageRenderer.roleLabel(message.role),
182− style = MaterialTheme.typography.labelSmall,
183− color = MaterialTheme.colorScheme.onSurfaceVariant,
246+ Row(
247+ verticalAlignment = Alignment.CenterVertically,
248+ horizontalArrangement = Arrangement.spacedBy(6.dp),
184249 modifier = Modifier.padding(bottom = 2.dp, start = 4.dp, end = 4.dp),
185− )
250+ ) {
251+ Text(
252+ MessageRenderer.roleLabel(message.role),
253+ style = MaterialTheme.typography.labelSmall,
254+ color = MaterialTheme.colorScheme.onSurfaceVariant,
255+ )
256+ if (message.createdAt > 0) {
257+ Text(
258+ "·",
259+ style = MaterialTheme.typography.labelSmall,
260+ color = MaterialTheme.colorScheme.onSurfaceVariant,
261+ )
262+ Text(
263+ formatTimestamp(message.createdAt),
264+ style = MaterialTheme.typography.labelSmall,
265+ color = MaterialTheme.colorScheme.onSurfaceVariant,
266+ )
267+ }
268+ }
186269 Box(
187270 modifier = Modifier
188271 .widthIn(max = 340.dp)
189272 .clip(RoundedCornerShape(16.dp))
190273 .background(bg)
274+ .clickable(
275+ enabled = message.parts.any { it.type == soy.iko.crush.proto.PartType.Text },
276+ onClick = {
277+ val text = MessageRenderer.renderText(message)
278+ if (text.isNotEmpty()) {
279+ val clipboard =
280+ context.getSystemService(android.content.Context.CLIPBOARD_SERVICE)
281+ as android.content.ClipboardManager
282+ clipboard.setPrimaryClip(
283+ android.content.ClipData.newPlainText("Crush", text),
284+ )
285+ }
286+ },
287+ )
191288 .padding(12.dp),
192289 ) {
193290 val text = MessageRenderer.renderText(message)
@@ -200,7 +297,6 @@ private fun MessageBubble(message: soy.iko.crush.proto.Message) {
200297 color = MaterialTheme.colorScheme.onSurface,
201298 )
202299 } else if (message.parts.isNotEmpty()) {
203− // Non-text parts (tool calls etc.) — render compactly.
204300 Text(
205301 message.parts.joinToString("\n") { MessageRenderer.renderPart(it) },
206302 style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
@@ -232,6 +328,14 @@ private fun InputBar(
232328 placeholder = { Text("Message Crush…") },
233329 maxLines = 5,
234330 enabled = enabled,
331+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
332+ keyboardActions = KeyboardActions(
333+ onSend = {
334+ if (enabled && text.isNotBlank()) {
335+ onSend()
336+ }
337+ },
338+ ),
235339 )
236340 Spacer(Modifier.size(8.dp))
237341 IconButton(onClick = onSend, enabled = enabled && text.isNotBlank()) {
@@ -243,16 +347,14 @@ private fun InputBar(
243347
244348@Composable
245349private fun ErrorBanner(message: String) {
246− Box(
247− modifier = Modifier
248− .fillMaxWidth()
249− .background(MaterialTheme.colorScheme.error.copy(alpha = 0.12f))
250− .padding(8.dp),
251− ) {
350+ Surface(color = MaterialTheme.colorScheme.errorContainer, tonalElevation = 2.dp) {
252351 Text(
253352 message,
254− color = MaterialTheme.colorScheme.error,
353+ color = MaterialTheme.colorScheme.onErrorContainer,
255354 style = MaterialTheme.typography.bodySmall,
355+ modifier = Modifier
356+ .fillMaxWidth()
357+ .padding(12.dp),
256358 )
257359 }
258360}
@@ -269,10 +371,36 @@ private fun TypingIndicator() {
269371 }
270372}
271373
374+@Composable
375+private fun EmptyConversation(modifier: Modifier = Modifier) {
376+ Column(
377+ modifier = modifier,
378+ horizontalAlignment = Alignment.CenterHorizontally,
379+ verticalArrangement = Arrangement.Center,
380+ ) {
381+ Text(
382+ "💬",
383+ style = MaterialTheme.typography.displayMedium,
384+ )
385+ Spacer(Modifier.height(8.dp))
386+ Text(
387+ "Say hello to Crush",
388+ style = MaterialTheme.typography.titleMedium,
389+ color = MaterialTheme.colorScheme.onSurface,
390+ )
391+ Spacer(Modifier.height(4.dp))
392+ Text(
393+ "Send a message below to start the conversation.",
394+ style = MaterialTheme.typography.bodySmall,
395+ color = MaterialTheme.colorScheme.onSurfaceVariant,
396+ )
397+ }
398+}
399+
272400@Composable
273401private fun PermissionQueue(
274− requests: List<soy.iko.crush.proto.PermissionRequest>,
275− onGrant: (soy.iko.crush.proto.PermissionRequest, Boolean) -> Unit,
402+ requests: List<PermissionRequest>,
403+ onGrant: (PermissionRequest, Boolean) -> Unit,
276404) {
277405 if (requests.isEmpty()) return
278406 val req = requests.first()
@@ -291,41 +419,96 @@ private fun PermissionQueue(
291419 )
292420}
293421
422+@OptIn(ExperimentalMaterial3Api::class)
294423@Composable
295424private fun SessionPickerSheet(
296− sessions: List<soy.iko.crush.proto.Session>,
425+ sessions: List<Session>,
297426 currentId: String?,
298427 onPick: (String?) -> Unit,
299428 onNew: () -> Unit,
300429 onDismiss: () -> Unit,
301430) {
302− AlertDialog(
431+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
432+ ModalBottomSheet(
303433 onDismissRequest = onDismiss,
304− title = { Text("Sessions") },
305− text = {
306− Column {
434+ sheetState = sheetState,
435+ ) {
436+ Column(
437+ modifier = Modifier
438+ .fillMaxWidth()
439+ .padding(horizontal = 16.dp, vertical = 8.dp),
440+ ) {
441+ Row(
442+ modifier = Modifier.fillMaxWidth(),
443+ horizontalArrangement = Arrangement.SpaceBetween,
444+ verticalAlignment = Alignment.CenterVertically,
445+ ) {
446+ Text(
447+ "Sessions",
448+ style = MaterialTheme.typography.titleLarge,
449+ fontWeight = FontWeight.Bold,
450+ )
451+ TextButton(onClick = onNew) { Text("New session") }
452+ }
453+ Spacer(Modifier.height(8.dp))
454+ if (sessions.isEmpty()) {
455+ Text(
456+ "No sessions yet",
457+ color = MaterialTheme.colorScheme.onSurfaceVariant,
458+ modifier = Modifier.padding(vertical = 16.dp),
459+ )
460+ } else {
307461 sessions.forEach { s ->
308− val active = s.id == currentId
309− Text(
310− s.title.ifBlank { s.id.take(8) },
311− fontWeight = if (active) FontWeight.Bold else FontWeight.Normal,
312− color = if (active) MaterialTheme.colorScheme.primary
313− else MaterialTheme.colorScheme.onSurface,
314− modifier = Modifier
315− .fillMaxWidth()
316− .padding(8.dp)
317− .clip(RoundedCornerShape(8.dp))
318− .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
319− .padding(8.dp),
320− )
321− Spacer(Modifier.height(4.dp))
322− }
323− if (sessions.isEmpty()) {
324− Text("No sessions yet", color = MaterialTheme.colorScheme.onSurfaceVariant)
462+ SessionRow(s, isActive = s.id == currentId, onClick = { onPick(s.id) })
463+ HorizontalDivider()
325464 }
326465 }
327− },
328− confirmButton = { Button(onNew) { Text("New session") } },
329− dismissButton = { TextButton(onDismiss) { Text("Close") } },
330− )
466+ Spacer(Modifier.height(16.dp))
467+ }
468+ }
469+}
470+
471+@Composable
472+private fun SessionRow(session: Session, isActive: Boolean, onClick: () -> Unit) {
473+ val title = session.title.ifBlank { session.id.take(8) }
474+ Column(
475+ modifier = Modifier
476+ .fillMaxWidth()
477+ .clickable(onClick = onClick)
478+ .padding(vertical = 12.dp, horizontal = 8.dp),
479+ ) {
480+ Row(
481+ modifier = Modifier.fillMaxWidth(),
482+ verticalAlignment = Alignment.CenterVertically,
483+ ) {
484+ Text(
485+ title,
486+ fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal,
487+ color = if (isActive) MaterialTheme.colorScheme.primary
488+ else MaterialTheme.colorScheme.onSurface,
489+ style = MaterialTheme.typography.bodyLarge,
490+ modifier = Modifier.weight(1f),
491+ )
492+ if (isActive) {
493+ Box(
494+ modifier = Modifier
495+ .size(8.dp)
496+ .clip(CircleShape)
497+ .background(MaterialTheme.colorScheme.primary),
498+ )
499+ }
500+ }
501+ if (session.messageCount > 0) {
502+ Text(
503+ "${session.messageCount} messages",
504+ style = MaterialTheme.typography.labelSmall,
505+ color = MaterialTheme.colorScheme.onSurfaceVariant,
506+ )
507+ }
508+ }
509+}
510+
511+private fun formatTimestamp(epochSeconds: Long): String {
512+ val timeMs = epochSeconds * 1000L
513+ return DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(timeMs))
331514}
app/src/main/java/soy/iko/crush/ui/screens/ConnectScreen.kt+67−10
@@ -1,13 +1,21 @@
11package soy.iko.crush.ui.screens
22
3+import androidx.compose.foundation.background
34import androidx.compose.foundation.layout.Arrangement
5+import androidx.compose.foundation.layout.Box
46import androidx.compose.foundation.layout.Column
57import androidx.compose.foundation.layout.Spacer
68import androidx.compose.foundation.layout.fillMaxSize
79import androidx.compose.foundation.layout.fillMaxWidth
810import androidx.compose.foundation.layout.height
911import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.layout.size
1013import androidx.compose.foundation.layout.widthIn
14+import androidx.compose.foundation.shape.RoundedCornerShape
15+import androidx.compose.foundation.text.KeyboardActions
16+import androidx.compose.foundation.text.KeyboardOptions
17+import androidx.compose.material.icons.Icons
18+import androidx.compose.material.icons.filled.RocketLaunch
1119import androidx.compose.material3.Button
1220import androidx.compose.material3.CircularProgressIndicator
1321import androidx.compose.material3.Icon
@@ -15,8 +23,6 @@ import androidx.compose.material3.MaterialTheme
1523import androidx.compose.material3.OutlinedTextField
1624import androidx.compose.material3.Surface
1725import androidx.compose.material3.Text
18−import androidx.compose.material.icons.Icons
19−import androidx.compose.material.icons.filled.Favorite
2026import androidx.compose.runtime.Composable
2127import androidx.compose.runtime.getValue
2228import androidx.compose.runtime.mutableStateOf
@@ -24,11 +30,12 @@ import androidx.compose.runtime.remember
2430import androidx.compose.runtime.setValue
2531import androidx.compose.ui.Alignment
2632import androidx.compose.ui.Modifier
33+import androidx.compose.ui.draw.clip
34+import androidx.compose.ui.graphics.Brush
2735import androidx.compose.ui.text.font.FontWeight
2836import androidx.compose.ui.text.input.ImeAction
2937import androidx.compose.ui.text.input.KeyboardCapitalization
3038import androidx.compose.ui.text.input.KeyboardType
31−import androidx.compose.ui.text.input.TextFieldValue
3239import androidx.compose.ui.unit.dp
3340import androidx.compose.ui.unit.sp
3441
@@ -37,6 +44,7 @@ fun ConnectScreen(
3744 defaultHost: String,
3845 defaultWorkspace: String,
3946 error: String?,
47+ isConnecting: Boolean,
4048 onConnect: (host: String, workspace: String) -> Unit,
4149) {
4250 var host by remember { mutableStateOf(defaultHost.ifBlank { "http://192.168.1.100:8080" }) }
@@ -53,12 +61,27 @@ fun ConnectScreen(
5361 horizontalAlignment = Alignment.CenterHorizontally,
5462 verticalArrangement = Arrangement.Center,
5563 ) {
56− Icon(
57− imageVector = Icons.Filled.Favorite,
58− contentDescription = null,
59− tint = MaterialTheme.colorScheme.primary,
60− modifier = Modifier.height(72.dp),
61− )
64+ Box(
65+ modifier = Modifier
66+ .size(88.dp)
67+ .clip(RoundedCornerShape(24.dp))
68+ .background(
69+ Brush.linearGradient(
70+ listOf(
71+ MaterialTheme.colorScheme.primary,
72+ MaterialTheme.colorScheme.secondary,
73+ ),
74+ ),
75+ ),
76+ contentAlignment = Alignment.Center,
77+ ) {
78+ Icon(
79+ imageVector = Icons.Filled.RocketLaunch,
80+ contentDescription = null,
81+ tint = MaterialTheme.colorScheme.onPrimary,
82+ modifier = Modifier.size(44.dp),
83+ )
84+ }
6285 Spacer(Modifier.height(12.dp))
6386 Text(
6487 "Crush",
@@ -83,14 +106,37 @@ fun ConnectScreen(
83106 value = host,
84107 onValueChange = { host = it },
85108 label = { Text("Server host") },
109+ placeholder = { Text("http://192.168.1.100:8080") },
86110 singleLine = true,
111+ enabled = !isConnecting,
112+ keyboardOptions = KeyboardOptions(
113+ keyboardType = KeyboardType.Uri,
114+ capitalization = KeyboardCapitalization.None,
115+ autoCorrectEnabled = false,
116+ imeAction = ImeAction.Next,
117+ ),
87118 modifier = Modifier.fillMaxWidth(),
88119 )
89120 OutlinedTextField(
90121 value = workspace,
91122 onValueChange = { workspace = it },
92123 label = { Text("Workspace path (on server)") },
124+ placeholder = { Text("/home/iko/project") },
93125 singleLine = true,
126+ enabled = !isConnecting,
127+ keyboardOptions = KeyboardOptions(
128+ keyboardType = KeyboardType.Ascii,
129+ capitalization = KeyboardCapitalization.None,
130+ autoCorrectEnabled = false,
131+ imeAction = ImeAction.Done,
132+ ),
133+ keyboardActions = KeyboardActions(
134+ onDone = {
135+ if (!isConnecting && host.isNotBlank() && workspace.isNotBlank()) {
136+ onConnect(host, workspace)
137+ }
138+ },
139+ ),
94140 modifier = Modifier.fillMaxWidth(),
95141 )
96142 if (error != null) {
@@ -102,9 +148,20 @@ fun ConnectScreen(
102148 }
103149 Button(
104150 onClick = { onConnect(host, workspace) },
151+ enabled = !isConnecting && host.isNotBlank() && workspace.isNotBlank(),
105152 modifier = Modifier.fillMaxWidth(),
106153 ) {
107− Text("Connect")
154+ if (isConnecting) {
155+ CircularProgressIndicator(
156+ modifier = Modifier.size(18.dp),
157+ strokeWidth = 2.dp,
158+ color = MaterialTheme.colorScheme.onPrimary,
159+ )
160+ Spacer(Modifier.size(8.dp))
161+ Text("Connecting…")
162+ } else {
163+ Text("Connect")
164+ }
108165 }
109166 }
110167 }