on master help sign in
app/build.gradle.kts+9−7
since splitsnapshots
unifiedsplit
@@ -44,14 +44,16 @@ android {
44 // (so CI can publish an installable APK). When the env vars are absent, the
44 // (so CI can publish an installable APK). When the env vars are absent, the
45 // release build falls back to unsigned — same behavior as before — so local
45 // release build falls back to unsigned — same behavior as before — so local
46 // builds keep working without any keystore setup.
46 // builds keep working without any keystore setup.
47− val releaseSigning = providers.environmentVariable("CRUSH_STORE_FILE").orNull?.let { path ->
47+ val releaseSigning = providers.environmentVariable("CRUSH_STORE_FILE").orNull
48+ ?.takeIf { it.isNotBlank() }
49+ ?.let { path ->
48− signingConfigs.create("release") {
50+ signingConfigs.create("release") {
49− storeFile = file(path)
51+ storeFile = file(path)
50− storePassword = providers.environmentVariable("CRUSH_STORE_PASSWORD").orNull
52+ storePassword = providers.environmentVariable("CRUSH_STORE_PASSWORD").orNull
51− keyAlias = providers.environmentVariable("CRUSH_KEY_ALIAS").orNull
53+ keyAlias = providers.environmentVariable("CRUSH_KEY_ALIAS").orNull
52− keyPassword = providers.environmentVariable("CRUSH_KEY_PASSWORD").orNull
54+ keyPassword = providers.environmentVariable("CRUSH_KEY_PASSWORD").orNull
55+ }
53 }
56 }
54− }
55
57
56 buildTypes {
58 buildTypes {
57 debug {
59 debug {
app/src/main/java/soy/iko/crush/MainActivity.kt+2
@@ -31,12 +31,14 @@ class MainActivity : ComponentActivity() {
31 onNewSession = vm::newSession,
31 onNewSession = vm::newSession,
32 onPickSession = vm::openSession,
32 onPickSession = vm::openSession,
33 onGrantPermission = vm::grantPermission,
33 onGrantPermission = vm::grantPermission,
34+ onDisconnect = vm::disconnect,
34 )
35 )
35 } else {
36 } else {
36 ConnectScreen(
37 ConnectScreen(
37 defaultHost = state.host,
38 defaultHost = state.host,
38 defaultWorkspace = state.workspacePath,
39 defaultWorkspace = state.workspacePath,
39 error = state.error,
40 error = state.error,
41+ isConnecting = state.isConnecting,
40 onConnect = vm::connect,
42 onConnect = vm::connect,
41 )
43 )
42 }
44 }
app/src/main/java/soy/iko/crush/data/CrushRepository.kt+10
@@ -70,6 +70,16 @@ class CrushRepository(
70 return true
70 return true
71 }
71 }
72
72
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+
73 suspend fun refreshWorkspaces() {
83 suspend fun refreshWorkspaces() {
74 _workspaces.value = runCatching { api.listWorkspaces() }.getOrDefault(emptyList())
84 _workspaces.value = runCatching { api.listWorkspaces() }.getOrDefault(emptyList())
75 }
85 }
app/src/main/java/soy/iko/crush/ui/ChatViewModel.kt+16
@@ -31,6 +31,7 @@ data class ChatUiState(
31 val messages: List<Message> = emptyList(),
31 val messages: List<Message> = emptyList(),
32 val pendingPermissions: List<PermissionRequest> = emptyList(),
32 val pendingPermissions: List<PermissionRequest> = emptyList(),
33 val isBusy: Boolean = false,
33 val isBusy: Boolean = false,
34+ val isConnecting: Boolean = false,
34 val error: String? = null,
35 val error: String? = null,
35)
36)
36
37
@@ -41,6 +42,7 @@ class ChatViewModel(
41
42
42 private val _error = MutableStateFlow<String?>(null)
43 private val _error = MutableStateFlow<String?>(null)
43 private val _busy = MutableStateFlow(false)
44 private val _busy = MutableStateFlow(false)
45+ private val _connecting = MutableStateFlow(false)
44
46
45 private var sseJob: Job? = null
47 private var sseJob: Job? = null
46
48
@@ -55,6 +57,7 @@ class ChatViewModel(
55 repo.permissions as Flow<Any>,
57 repo.permissions as Flow<Any>,
56 _busy as Flow<Any>,
58 _busy as Flow<Any>,
57 _error as Flow<Any>,
59 _error as Flow<Any>,
60+ _connecting as Flow<Any>,
58 ) { values ->
61 ) { values ->
59 ChatUiState(
62 ChatUiState(
60 connection = values[0] as ConnectionState,
63 connection = values[0] as ConnectionState,
@@ -67,6 +70,7 @@ class ChatViewModel(
67 pendingPermissions = values[5] as List<PermissionRequest>,
70 pendingPermissions = values[5] as List<PermissionRequest>,
68 isBusy = values[6] as Boolean,
71 isBusy = values[6] as Boolean,
69 error = values[7] as String?,
72 error = values[7] as String?,
73+ isConnecting = values[8] as Boolean,
70 )
74 )
71 }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ChatUiState())
75 }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ChatUiState())
72
76
@@ -75,22 +79,34 @@ class ChatViewModel(
75 fun connect(host: String, workspacePath: String) {
79 fun connect(host: String, workspacePath: String) {
76 viewModelScope.launch(Dispatchers.IO) {
80 viewModelScope.launch(Dispatchers.IO) {
77 _error.value = null
81 _error.value = null
82+ _connecting.value = true
78 settings.setHost(host)
83 settings.setHost(host)
79 settings.setWorkspace(workspacePath)
84 settings.setWorkspace(workspacePath)
80 val ok = repo.connect(host)
85 val ok = repo.connect(host)
81 if (!ok) {
86 if (!ok) {
82 _error.value = "Could not reach crush server at $host"
87 _error.value = "Could not reach crush server at $host"
88+ _connecting.value = false
83 return@launch
89 return@launch
84 }
90 }
85 val ws = repo.openWorkspace(workspacePath)
91 val ws = repo.openWorkspace(workspacePath)
86 if (ws == null) {
92 if (ws == null) {
87 _error.value = "Could not open workspace '$workspacePath'"
93 _error.value = "Could not open workspace '$workspacePath'"
94+ _connecting.value = false
88 return@launch
95 return@launch
89 }
96 }
97+ _connecting.value = false
90 startEventStream()
98 startEventStream()
91 }
99 }
92 }
100 }
93
101
102+ fun disconnect() {
103+ sseJob?.cancel()
104+ repo.disconnect()
105+ _busy.value = false
106+ _connecting.value = false
107+ _error.value = null
108+ }
109+
94 fun newSession() {
110 fun newSession() {
95 viewModelScope.launch(Dispatchers.IO) {
111 viewModelScope.launch(Dispatchers.IO) {
96 _error.value = null
112 _error.value = null
app/src/main/java/soy/iko/crush/ui/screens/ChatScreen.kt+246−63
@@ -1,6 +1,7 @@
1package soy.iko.crush.ui.screens
1package soy.iko.crush.ui.screens
2
2
3import androidx.compose.foundation.background
3import androidx.compose.foundation.background
4+import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Column
@@ -15,28 +16,33 @@ import androidx.compose.foundation.layout.widthIn
15import androidx.compose.foundation.lazy.LazyColumn
16import androidx.compose.foundation.lazy.LazyColumn
16import androidx.compose.foundation.lazy.items
17import androidx.compose.foundation.lazy.items
17import androidx.compose.foundation.lazy.rememberLazyListState
18import androidx.compose.foundation.lazy.rememberLazyListState
19+import androidx.compose.foundation.shape.CircleShape
18import androidx.compose.foundation.shape.RoundedCornerShape
20import androidx.compose.foundation.shape.RoundedCornerShape
21+import androidx.compose.foundation.text.KeyboardActions
22+import androidx.compose.foundation.text.KeyboardOptions
19import androidx.compose.material.icons.Icons
23import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.automirrored.filled.Send
24import androidx.compose.material.icons.automirrored.filled.Send
21import androidx.compose.material.icons.filled.Add
25import androidx.compose.material.icons.filled.Add
26+import androidx.compose.material.icons.filled.MoreVert
22import androidx.compose.material.icons.filled.Stop
27import androidx.compose.material.icons.filled.Stop
23import androidx.compose.material3.AlertDialog
28import androidx.compose.material3.AlertDialog
24import androidx.compose.material3.Button
29import androidx.compose.material3.Button
25−import androidx.compose.material3.Card
26−import androidx.compose.material3.CardDefaults
27import androidx.compose.material3.CircularProgressIndicator
30import androidx.compose.material3.CircularProgressIndicator
31+import androidx.compose.material3.DropdownMenu
32+import androidx.compose.material3.DropdownMenuItem
28import androidx.compose.material3.ExperimentalMaterial3Api
33import androidx.compose.material3.ExperimentalMaterial3Api
29−import androidx.compose.material3.FloatingActionButton
30import androidx.compose.material3.HorizontalDivider
34import androidx.compose.material3.HorizontalDivider
31import androidx.compose.material3.Icon
35import androidx.compose.material3.Icon
32import androidx.compose.material3.IconButton
36import androidx.compose.material3.IconButton
33import androidx.compose.material3.MaterialTheme
37import androidx.compose.material3.MaterialTheme
38+import androidx.compose.material3.ModalBottomSheet
34import androidx.compose.material3.OutlinedTextField
39import androidx.compose.material3.OutlinedTextField
35import androidx.compose.material3.Scaffold
40import androidx.compose.material3.Scaffold
36import androidx.compose.material3.Surface
41import androidx.compose.material3.Surface
37import androidx.compose.material3.Text
42import androidx.compose.material3.Text
38import androidx.compose.material3.TextButton
43import androidx.compose.material3.TextButton
39import androidx.compose.material3.TopAppBar
44import androidx.compose.material3.TopAppBar
45+import androidx.compose.material3.rememberModalBottomSheetState
40import androidx.compose.runtime.Composable
46import androidx.compose.runtime.Composable
41import androidx.compose.runtime.LaunchedEffect
47import androidx.compose.runtime.LaunchedEffect
42import androidx.compose.runtime.getValue
48import androidx.compose.runtime.getValue
@@ -46,12 +52,19 @@ import androidx.compose.runtime.setValue
46import androidx.compose.ui.Alignment
52import androidx.compose.ui.Alignment
47import androidx.compose.ui.Modifier
53import androidx.compose.ui.Modifier
48import androidx.compose.ui.draw.clip
54import androidx.compose.ui.draw.clip
55+import androidx.compose.ui.platform.LocalContext
49import androidx.compose.ui.text.font.FontFamily
56import androidx.compose.ui.text.font.FontFamily
50import androidx.compose.ui.text.font.FontWeight
57import androidx.compose.ui.text.font.FontWeight
58+import androidx.compose.ui.text.input.ImeAction
51import androidx.compose.ui.unit.dp
59import androidx.compose.ui.unit.dp
60+import soy.iko.crush.proto.Message
52import soy.iko.crush.proto.MessageRole
61import soy.iko.crush.proto.MessageRole
62+import soy.iko.crush.proto.PermissionRequest
63+import soy.iko.crush.proto.Session
53import soy.iko.crush.ui.ChatUiState
64import soy.iko.crush.ui.ChatUiState
54import soy.iko.crush.ui.MessageRenderer
65import soy.iko.crush.ui.MessageRenderer
66+import java.text.DateFormat
67+import java.util.Date
55
68
56@OptIn(ExperimentalMaterial3Api::class)
69@OptIn(ExperimentalMaterial3Api::class)
57@Composable
70@Composable
@@ -61,13 +74,14 @@ fun ChatScreen(
61 onCancel: () -> Unit,
74 onCancel: () -> Unit,
62 onNewSession: () -> Unit,
75 onNewSession: () -> Unit,
63 onPickSession: (String) -> Unit,
76 onPickSession: (String) -> Unit,
64− onGrantPermission: (soy.iko.crush.proto.PermissionRequest, Boolean) -> Unit,
77+ onGrantPermission: (PermissionRequest, Boolean) -> Unit,
78+ onDisconnect: () -> Unit,
65) {
79) {
66 var input by remember { mutableStateOf("") }
80 var input by remember { mutableStateOf("") }
67 var showSessionPicker by remember { mutableStateOf(false) }
81 var showSessionPicker by remember { mutableStateOf(false) }
82+ var showOverflow by remember { mutableStateOf(false) }
68 val listState = rememberLazyListState()
83 val listState = rememberLazyListState()
69
84
70− // Auto-scroll to bottom when new messages arrive.
71 LaunchedEffect(state.messages.size) {
85 LaunchedEffect(state.messages.size) {
72 if (state.messages.isNotEmpty()) {
86 if (state.messages.isNotEmpty()) {
73 listState.animateScrollToItem(state.messages.lastIndex)
87 listState.animateScrollToItem(state.messages.lastIndex)
@@ -79,8 +93,12 @@ fun ChatScreen(
79 TopAppBar(
93 TopAppBar(
80 title = {
94 title = {
81 Column {
95 Column {
96+ Row(verticalAlignment = Alignment.CenterVertically) {
82− Text("Crush", fontWeight = FontWeight.Bold)
97+ Text("Crush", fontWeight = FontWeight.Bold)
98+ Spacer(Modifier.size(6.dp))
99+ StatusDot(state)
100+ }
83− val subtitle = state.currentSessionId?.let { "session $it" } ?: "no session"
101+ val subtitle = state.currentSessionId?.let { "session ${it.take(8)}" } ?: "no session"
84 Text(
102 Text(
85 subtitle,
103 subtitle,
86 style = MaterialTheme.typography.labelSmall,
104 style = MaterialTheme.typography.labelSmall,
@@ -101,6 +119,30 @@ fun ChatScreen(
101 IconButton(onClick = { showSessionPicker = true }) {
119 IconButton(onClick = { showSessionPicker = true }) {
102 Icon(Icons.Filled.Add, contentDescription = "Sessions")
120 Icon(Icons.Filled.Add, contentDescription = "Sessions")
103 }
121 }
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+ }
104 },
146 },
105 )
147 )
106 },
148 },
@@ -126,20 +168,28 @@ fun ChatScreen(
126 if (state.error != null) {
168 if (state.error != null) {
127 ErrorBanner(state.error)
169 ErrorBanner(state.error)
128 }
170 }
171+ if (state.messages.isEmpty() && !state.isBusy) {
172+ EmptyConversation(
173+ modifier = Modifier
174+ .weight(1f)
175+ .fillMaxWidth(),
176+ )
177+ } else {
129− LazyColumn(
178+ LazyColumn(
130− state = listState,
179+ state = listState,
131− modifier = Modifier
180+ modifier = Modifier
132− .weight(1f)
181+ .weight(1f)
133− .fillMaxWidth()
182+ .fillMaxWidth()
134− .padding(horizontal = 12.dp),
183+ .padding(horizontal = 12.dp),
135− verticalArrangement = Arrangement.spacedBy(8.dp),
184+ verticalArrangement = Arrangement.spacedBy(8.dp),
136− contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp),
185+ contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp),
137− ) {
186+ ) {
138− items(state.messages, key = { it.id }) { msg ->
187+ items(state.messages, key = { it.id }) { msg ->
139− MessageBubble(msg)
188+ MessageBubble(msg)
140− }
189+ }
141− if (state.isBusy && state.messages.isEmpty()) {
190+ if (state.isBusy && state.messages.isEmpty()) {
142− item { TypingIndicator() }
191+ item { TypingIndicator() }
192+ }
143 }
193 }
144 }
194 }
145 PermissionQueue(
195 PermissionQueue(
@@ -167,27 +217,74 @@ fun ChatScreen(
167}
217}
168
218
169@Composable
219@Composable
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
170−private fun MessageBubble(message: soy.iko.crush.proto.Message) {
235+private fun MessageBubble(message: Message) {
171 val isUser = message.role == MessageRole.user
236 val isUser = message.role == MessageRole.user
172 val bg = if (isUser) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
237 val bg = if (isUser) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
173 else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
238 else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
174 val alignment = if (isUser) Alignment.End else Alignment.Start
239 val alignment = if (isUser) Alignment.End else Alignment.Start
240+ val context = LocalContext.current
175
241
176 Column(
242 Column(
177 modifier = Modifier.fillMaxWidth(),
243 modifier = Modifier.fillMaxWidth(),
178 horizontalAlignment = alignment,
244 horizontalAlignment = alignment,
179 ) {
245 ) {
180− Text(
246+ Row(
181− MessageRenderer.roleLabel(message.role),
247+ verticalAlignment = Alignment.CenterVertically,
182− style = MaterialTheme.typography.labelSmall,
248+ horizontalArrangement = Arrangement.spacedBy(6.dp),
183− color = MaterialTheme.colorScheme.onSurfaceVariant,
184 modifier = Modifier.padding(bottom = 2.dp, start = 4.dp, end = 4.dp),
249 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+ }
186 Box(
269 Box(
187 modifier = Modifier
270 modifier = Modifier
188 .widthIn(max = 340.dp)
271 .widthIn(max = 340.dp)
189 .clip(RoundedCornerShape(16.dp))
272 .clip(RoundedCornerShape(16.dp))
190 .background(bg)
273 .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+ )
191 .padding(12.dp),
288 .padding(12.dp),
192 ) {
289 ) {
193 val text = MessageRenderer.renderText(message)
290 val text = MessageRenderer.renderText(message)
@@ -200,7 +297,6 @@ private fun MessageBubble(message: soy.iko.crush.proto.Message) {
200 color = MaterialTheme.colorScheme.onSurface,
297 color = MaterialTheme.colorScheme.onSurface,
201 )
298 )
202 } else if (message.parts.isNotEmpty()) {
299 } else if (message.parts.isNotEmpty()) {
203− // Non-text parts (tool calls etc.) — render compactly.
204 Text(
300 Text(
205 message.parts.joinToString("\n") { MessageRenderer.renderPart(it) },
301 message.parts.joinToString("\n") { MessageRenderer.renderPart(it) },
206 style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
302 style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
@@ -232,6 +328,14 @@ private fun InputBar(
232 placeholder = { Text("Message Crush…") },
328 placeholder = { Text("Message Crush…") },
233 maxLines = 5,
329 maxLines = 5,
234 enabled = enabled,
330 enabled = enabled,
331+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
332+ keyboardActions = KeyboardActions(
333+ onSend = {
334+ if (enabled && text.isNotBlank()) {
335+ onSend()
336+ }
337+ },
338+ ),
235 )
339 )
236 Spacer(Modifier.size(8.dp))
340 Spacer(Modifier.size(8.dp))
237 IconButton(onClick = onSend, enabled = enabled && text.isNotBlank()) {
341 IconButton(onClick = onSend, enabled = enabled && text.isNotBlank()) {
@@ -243,16 +347,14 @@ private fun InputBar(
243
347
244@Composable
348@Composable
245private fun ErrorBanner(message: String) {
349private fun ErrorBanner(message: String) {
246− Box(
247− modifier = Modifier
248− .fillMaxWidth()
249− .background(MaterialTheme.colorScheme.error.copy(alpha = 0.12f))
350+ Surface(color = MaterialTheme.colorScheme.errorContainer, tonalElevation = 2.dp) {
250− .padding(8.dp),
251− ) {
252 Text(
351 Text(
253 message,
352 message,
254− color = MaterialTheme.colorScheme.error,
353+ color = MaterialTheme.colorScheme.onErrorContainer,
255 style = MaterialTheme.typography.bodySmall,
354 style = MaterialTheme.typography.bodySmall,
355+ modifier = Modifier
356+ .fillMaxWidth()
357+ .padding(12.dp),
256 )
358 )
257 }
359 }
258}
360}
@@ -269,10 +371,36 @@ private fun TypingIndicator() {
269 }
371 }
270}
372}
271
373
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+
272@Composable
400@Composable
273private fun PermissionQueue(
401private fun PermissionQueue(
274− requests: List<soy.iko.crush.proto.PermissionRequest>,
402+ requests: List<PermissionRequest>,
275− onGrant: (soy.iko.crush.proto.PermissionRequest, Boolean) -> Unit,
403+ onGrant: (PermissionRequest, Boolean) -> Unit,
276) {
404) {
277 if (requests.isEmpty()) return
405 if (requests.isEmpty()) return
278 val req = requests.first()
406 val req = requests.first()
@@ -291,41 +419,96 @@ private fun PermissionQueue(
291 )
419 )
292}
420}
293
421
422+@OptIn(ExperimentalMaterial3Api::class)
294@Composable
423@Composable
295private fun SessionPickerSheet(
424private fun SessionPickerSheet(
296− sessions: List<soy.iko.crush.proto.Session>,
425+ sessions: List<Session>,
297 currentId: String?,
426 currentId: String?,
298 onPick: (String?) -> Unit,
427 onPick: (String?) -> Unit,
299 onNew: () -> Unit,
428 onNew: () -> Unit,
300 onDismiss: () -> Unit,
429 onDismiss: () -> Unit,
301) {
430) {
302− AlertDialog(
431+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
432+ ModalBottomSheet(
303 onDismissRequest = onDismiss,
433 onDismissRequest = onDismiss,
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(
304− title = { Text("Sessions") },
447+ "Sessions",
305− text = {
448+ style = MaterialTheme.typography.titleLarge,
306− Column {
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 {
307 sessions.forEach { s ->
461 sessions.forEach { s ->
308− val active = s.id == currentId
462+ SessionRow(s, isActive = s.id == currentId, onClick = { onPick(s.id) })
309− Text(
463+ HorizontalDivider()
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)
325 }
464 }
326 }
465 }
466+ Spacer(Modifier.height(16.dp))
327− },
467+ }
328− confirmButton = { Button(onNew) { Text("New session") } },
468+ }
329− dismissButton = { TextButton(onDismiss) { Text("Close") } },
469+}
330− )
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))
331}
514}
app/src/main/java/soy/iko/crush/ui/screens/ConnectScreen.kt+67−10
@@ -1,13 +1,21 @@
1package soy.iko.crush.ui.screens
1package soy.iko.crush.ui.screens
2
2
3+import androidx.compose.foundation.background
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Arrangement
5+import androidx.compose.foundation.layout.Box
4import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Spacer
7import androidx.compose.foundation.layout.Spacer
6import androidx.compose.foundation.layout.fillMaxSize
8import androidx.compose.foundation.layout.fillMaxSize
7import androidx.compose.foundation.layout.fillMaxWidth
9import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.height
10import androidx.compose.foundation.layout.height
9import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.layout.size
10import androidx.compose.foundation.layout.widthIn
13import 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
11import androidx.compose.material3.Button
19import androidx.compose.material3.Button
12import androidx.compose.material3.CircularProgressIndicator
20import androidx.compose.material3.CircularProgressIndicator
13import androidx.compose.material3.Icon
21import androidx.compose.material3.Icon
@@ -15,8 +23,6 @@ import androidx.compose.material3.MaterialTheme
15import androidx.compose.material3.OutlinedTextField
23import androidx.compose.material3.OutlinedTextField
16import androidx.compose.material3.Surface
24import androidx.compose.material3.Surface
17import androidx.compose.material3.Text
25import androidx.compose.material3.Text
18−import androidx.compose.material.icons.Icons
19−import androidx.compose.material.icons.filled.Favorite
20import androidx.compose.runtime.Composable
26import androidx.compose.runtime.Composable
21import androidx.compose.runtime.getValue
27import androidx.compose.runtime.getValue
22import androidx.compose.runtime.mutableStateOf
28import androidx.compose.runtime.mutableStateOf
@@ -24,11 +30,12 @@ import androidx.compose.runtime.remember
24import androidx.compose.runtime.setValue
30import androidx.compose.runtime.setValue
25import androidx.compose.ui.Alignment
31import androidx.compose.ui.Alignment
26import androidx.compose.ui.Modifier
32import androidx.compose.ui.Modifier
33+import androidx.compose.ui.draw.clip
34+import androidx.compose.ui.graphics.Brush
27import androidx.compose.ui.text.font.FontWeight
35import androidx.compose.ui.text.font.FontWeight
28import androidx.compose.ui.text.input.ImeAction
36import androidx.compose.ui.text.input.ImeAction
29import androidx.compose.ui.text.input.KeyboardCapitalization
37import androidx.compose.ui.text.input.KeyboardCapitalization
30import androidx.compose.ui.text.input.KeyboardType
38import androidx.compose.ui.text.input.KeyboardType
31−import androidx.compose.ui.text.input.TextFieldValue
32import androidx.compose.ui.unit.dp
39import androidx.compose.ui.unit.dp
33import androidx.compose.ui.unit.sp
40import androidx.compose.ui.unit.sp
34
41
@@ -37,6 +44,7 @@ fun ConnectScreen(
37 defaultHost: String,
44 defaultHost: String,
38 defaultWorkspace: String,
45 defaultWorkspace: String,
39 error: String?,
46 error: String?,
47+ isConnecting: Boolean,
40 onConnect: (host: String, workspace: String) -> Unit,
48 onConnect: (host: String, workspace: String) -> Unit,
41) {
49) {
42 var host by remember { mutableStateOf(defaultHost.ifBlank { "http://192.168.1.100:8080" }) }
50 var host by remember { mutableStateOf(defaultHost.ifBlank { "http://192.168.1.100:8080" }) }
@@ -53,12 +61,27 @@ fun ConnectScreen(
53 horizontalAlignment = Alignment.CenterHorizontally,
61 horizontalAlignment = Alignment.CenterHorizontally,
54 verticalArrangement = Arrangement.Center,
62 verticalArrangement = Arrangement.Center,
55 ) {
63 ) {
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+ ) {
56− Icon(
78+ Icon(
57− imageVector = Icons.Filled.Favorite,
79+ imageVector = Icons.Filled.RocketLaunch,
58− contentDescription = null,
80+ contentDescription = null,
59− tint = MaterialTheme.colorScheme.primary,
81+ tint = MaterialTheme.colorScheme.onPrimary,
60− modifier = Modifier.height(72.dp),
82+ modifier = Modifier.size(44.dp),
61− )
83+ )
84+ }
62 Spacer(Modifier.height(12.dp))
85 Spacer(Modifier.height(12.dp))
63 Text(
86 Text(
64 "Crush",
87 "Crush",
@@ -83,14 +106,37 @@ fun ConnectScreen(
83 value = host,
106 value = host,
84 onValueChange = { host = it },
107 onValueChange = { host = it },
85 label = { Text("Server host") },
108 label = { Text("Server host") },
109+ placeholder = { Text("http://192.168.1.100:8080") },
86 singleLine = true,
110 singleLine = true,
111+ enabled = !isConnecting,
112+ keyboardOptions = KeyboardOptions(
113+ keyboardType = KeyboardType.Uri,
114+ capitalization = KeyboardCapitalization.None,
115+ autoCorrectEnabled = false,
116+ imeAction = ImeAction.Next,
117+ ),
87 modifier = Modifier.fillMaxWidth(),
118 modifier = Modifier.fillMaxWidth(),
88 )
119 )
89 OutlinedTextField(
120 OutlinedTextField(
90 value = workspace,
121 value = workspace,
91 onValueChange = { workspace = it },
122 onValueChange = { workspace = it },
92 label = { Text("Workspace path (on server)") },
123 label = { Text("Workspace path (on server)") },
124+ placeholder = { Text("/home/iko/project") },
93 singleLine = true,
125 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+ ),
94 modifier = Modifier.fillMaxWidth(),
140 modifier = Modifier.fillMaxWidth(),
95 )
141 )
96 if (error != null) {
142 if (error != null) {
@@ -102,9 +148,20 @@ fun ConnectScreen(
102 }
148 }
103 Button(
149 Button(
104 onClick = { onConnect(host, workspace) },
150 onClick = { onConnect(host, workspace) },
151+ enabled = !isConnecting && host.isNotBlank() && workspace.isNotBlank(),
105 modifier = Modifier.fillMaxWidth(),
152 modifier = Modifier.fillMaxWidth(),
106 ) {
153 ) {
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 {
107− Text("Connect")
163+ Text("Connect")
164+ }
108 }
165 }
109 }
166 }
110 }
167 }