From 3e57ca5d72dd1b0b9d36ba0792e80178f79494dc Mon Sep 17 00:00:00 2001 From: yishai Date: Wed, 28 Jan 2026 22:14:16 +0200 Subject: [PATCH] Android: brighter menu, always-visible orb, camera & file upload 1. Menu now brighter (0xFF3A3A4A) for better contrast 2. Orb on main page always visible (static when off, pulsing when on) 3. Implemented camera capture (saves to MediaStore, loads as attachment) 4. Implemented file picker (any file type) 5. Attachment type auto-detected (image vs file) based on MIME --- .../com/clawdbot/android/ui/RootScreen.kt | 22 +++--- .../clawdbot/android/ui/chat/ChatComposer.kt | 16 ++-- .../android/ui/chat/ChatSheetContent.kt | 73 +++++++++++++++++-- 3 files changed, 84 insertions(+), 27 deletions(-) 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 ->