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