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
This commit is contained in:
yishai 2026-01-28 22:14:16 +02:00
parent fbf18d55b8
commit 3e57ca5d72
3 changed files with 84 additions and 27 deletions

View File

@ -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

View File

@ -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),

View File

@ -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<PendingImageAttachment>() }
var cameraImageUri by remember { mutableStateOf<Uri?>(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 ->