diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/RootScreen.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/RootScreen.kt index 5cf6d9ec6..9b58d9e75 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/RootScreen.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/ui/RootScreen.kt @@ -267,18 +267,16 @@ fun RootScreen(viewModel: MainViewModel) { } } - // PTT orb overlay (only show when talk mode is active, for visual feedback) - if (talkEnabled) { - Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { - TalkOrbOverlay( - seamColor = seamColor, - statusText = talkStatusText, - isListening = talkIsListening, - isSpeaking = talkIsSpeaking, - isActive = true, - onTap = { viewModel.setTalkEnabled(false) }, - ) - } + // PTT orb overlay - always visible, static when off, pulsing when active + Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { + TalkOrbOverlay( + seamColor = seamColor, + statusText = talkStatusText, + isListening = talkIsListening, + isSpeaking = talkIsSpeaking, + isActive = talkEnabled, + onTap = { viewModel.setTalkEnabled(!talkEnabled) }, + ) } val currentSheet = sheet diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatComposer.kt index b6f3e3626..2d73b98a7 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatComposer.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatComposer.kt @@ -147,7 +147,7 @@ fun ChatComposer( DropdownMenu( expanded = showMenu, onDismissRequest = { showMenu = false }, - modifier = Modifier.background(Color(0xFF1E1E2E)), // Darker shade + modifier = Modifier.background(Color(0xFF3A3A4A)), // Brighter shade for contrast ) { // Attachment buttons row: Camera | Photos | Files Row( @@ -187,7 +187,7 @@ fun ChatComposer( HorizontalDivider( modifier = Modifier.padding(vertical = 8.dp), - color = Color.White.copy(alpha = 0.1f), + color = Color.White.copy(alpha = 0.15f), ) // Session selector @@ -197,8 +197,8 @@ fun ChatComposer( showMenu = false showSessionMenu = true }, - leadingIcon = { Icon(Icons.Default.Settings, contentDescription = null, tint = Color.White.copy(alpha = 0.7f)) }, - trailingIcon = { Text(currentSessionLabel, color = Color.White.copy(alpha = 0.5f), style = MaterialTheme.typography.bodySmall) }, + leadingIcon = { Icon(Icons.Default.Settings, contentDescription = null, tint = Color.White.copy(alpha = 0.8f)) }, + trailingIcon = { Text(currentSessionLabel, color = Color.White.copy(alpha = 0.6f), style = MaterialTheme.typography.bodySmall) }, ) // Thinking level @@ -211,12 +211,12 @@ fun ChatComposer( leadingIcon = { Text("🧠", style = MaterialTheme.typography.bodyLarge) }, - trailingIcon = { Text(thinkingLabel(thinkingLevel), color = Color.White.copy(alpha = 0.5f), style = MaterialTheme.typography.bodySmall) }, + trailingIcon = { Text(thinkingLabel(thinkingLevel), color = Color.White.copy(alpha = 0.6f), style = MaterialTheme.typography.bodySmall) }, ) HorizontalDivider( modifier = Modifier.padding(vertical = 8.dp), - color = Color.White.copy(alpha = 0.1f), + color = Color.White.copy(alpha = 0.15f), ) // Refresh @@ -226,7 +226,7 @@ fun ChatComposer( showMenu = false onRefresh() }, - leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null, tint = Color.White.copy(alpha = 0.7f)) }, + leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null, tint = Color.White.copy(alpha = 0.8f)) }, ) } } @@ -346,7 +346,7 @@ private fun AttachmentButton( onClick = onClick, modifier = modifier.height(80.dp), shape = RoundedCornerShape(12.dp), - color = Color(0xFF2A2A3E), + color = Color(0xFF4A4A5A), ) { Column( modifier = Modifier.padding(12.dp), diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatSheetContent.kt index 388a8a79c..ec2fefe94 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatSheetContent.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/ui/chat/ChatSheetContent.kt @@ -1,7 +1,10 @@ package com.clawdbot.android.ui.chat import android.content.ContentResolver +import android.content.ContentValues import android.net.Uri +import android.os.Build +import android.provider.MediaStore import android.util.Base64 import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -14,8 +17,10 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp @@ -52,7 +57,9 @@ fun ChatSheetContent(viewModel: MainViewModel) { val scope = rememberCoroutineScope() val attachments = remember { mutableStateListOf() } + var cameraImageUri by remember { mutableStateOf(null) } + // Image picker val pickImages = rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> if (uris.isNullOrEmpty()) return@rememberLauncherForActivityResult @@ -60,7 +67,7 @@ fun ChatSheetContent(viewModel: MainViewModel) { val next = uris.take(8).mapNotNull { uri -> try { - loadImageAttachment(resolver, uri) + loadAttachment(resolver, uri) } catch (_: Throwable) { null } @@ -71,6 +78,54 @@ fun ChatSheetContent(viewModel: MainViewModel) { } } + // File picker (any file type) + val pickFiles = + rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> + if (uris.isNullOrEmpty()) return@rememberLauncherForActivityResult + scope.launch(Dispatchers.IO) { + val next = + uris.take(8).mapNotNull { uri -> + try { + loadAttachment(resolver, uri) + } catch (_: Throwable) { + null + } + } + withContext(Dispatchers.Main) { + attachments.addAll(next) + } + } + } + + // Camera capture + val takePicture = + rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success -> + if (success && cameraImageUri != null) { + scope.launch(Dispatchers.IO) { + try { + val att = loadAttachment(resolver, cameraImageUri!!) + withContext(Dispatchers.Main) { + attachments.add(att) + } + } catch (_: Throwable) { + // Failed to load camera image + } + } + } + } + + fun openCamera() { + val contentValues = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, "camera_${System.currentTimeMillis()}.jpg") + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + } + val uri = context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) + if (uri != null) { + cameraImageUri = uri + takePicture.launch(uri) + } + } + Column( modifier = Modifier @@ -97,9 +152,9 @@ fun ChatSheetContent(viewModel: MainViewModel) { attachments = attachments, seamColor = seamColor, talkEnabled = talkEnabled, - onOpenCamera = { /* TODO: Implement camera capture */ }, + onOpenCamera = { openCamera() }, onPickImages = { pickImages.launch("image/*") }, - onPickFiles = { /* TODO: Implement file picker */ }, + onPickFiles = { pickFiles.launch(arrayOf("*/*")) }, onRemoveAttachment = { id -> attachments.removeAll { it.id == id } }, onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) }, onSelectSession = { key -> viewModel.switchChatSession(key) }, @@ -112,8 +167,12 @@ fun ChatSheetContent(viewModel: MainViewModel) { onSend = { text -> val outgoing = attachments.map { att -> + val type = when { + att.mimeType.startsWith("image/") -> "image" + else -> "file" + } OutgoingAttachment( - type = "image", + type = type, mimeType = att.mimeType, fileName = att.fileName, base64 = att.base64, @@ -133,9 +192,9 @@ data class PendingImageAttachment( val base64: String, ) -private suspend fun loadImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment { - val mimeType = resolver.getType(uri) ?: "image/*" - val fileName = (uri.lastPathSegment ?: "image").substringAfterLast('/') +private suspend fun loadAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment { + val mimeType = resolver.getType(uri) ?: "application/octet-stream" + val fileName = (uri.lastPathSegment ?: "file").substringAfterLast('/') val bytes = withContext(Dispatchers.IO) { resolver.openInputStream(uri)?.use { input ->