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) // PTT orb overlay - always visible, static when off, pulsing when active
if (talkEnabled) { Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { TalkOrbOverlay(
TalkOrbOverlay( seamColor = seamColor,
seamColor = seamColor, statusText = talkStatusText,
statusText = talkStatusText, isListening = talkIsListening,
isListening = talkIsListening, isSpeaking = talkIsSpeaking,
isSpeaking = talkIsSpeaking, isActive = talkEnabled,
isActive = true, onTap = { viewModel.setTalkEnabled(!talkEnabled) },
onTap = { viewModel.setTalkEnabled(false) }, )
)
}
} }
val currentSheet = sheet val currentSheet = sheet

View File

@ -147,7 +147,7 @@ fun ChatComposer(
DropdownMenu( DropdownMenu(
expanded = showMenu, expanded = showMenu,
onDismissRequest = { showMenu = false }, 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 // Attachment buttons row: Camera | Photos | Files
Row( Row(
@ -187,7 +187,7 @@ fun ChatComposer(
HorizontalDivider( HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp), modifier = Modifier.padding(vertical = 8.dp),
color = Color.White.copy(alpha = 0.1f), color = Color.White.copy(alpha = 0.15f),
) )
// Session selector // Session selector
@ -197,8 +197,8 @@ fun ChatComposer(
showMenu = false showMenu = false
showSessionMenu = true showSessionMenu = true
}, },
leadingIcon = { Icon(Icons.Default.Settings, contentDescription = null, tint = Color.White.copy(alpha = 0.7f)) }, leadingIcon = { Icon(Icons.Default.Settings, contentDescription = null, tint = Color.White.copy(alpha = 0.8f)) },
trailingIcon = { Text(currentSessionLabel, color = Color.White.copy(alpha = 0.5f), style = MaterialTheme.typography.bodySmall) }, trailingIcon = { Text(currentSessionLabel, color = Color.White.copy(alpha = 0.6f), style = MaterialTheme.typography.bodySmall) },
) )
// Thinking level // Thinking level
@ -211,12 +211,12 @@ fun ChatComposer(
leadingIcon = { leadingIcon = {
Text("🧠", style = MaterialTheme.typography.bodyLarge) 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( HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp), modifier = Modifier.padding(vertical = 8.dp),
color = Color.White.copy(alpha = 0.1f), color = Color.White.copy(alpha = 0.15f),
) )
// Refresh // Refresh
@ -226,7 +226,7 @@ fun ChatComposer(
showMenu = false showMenu = false
onRefresh() 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, onClick = onClick,
modifier = modifier.height(80.dp), modifier = modifier.height(80.dp),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = Color(0xFF2A2A3E), color = Color(0xFF4A4A5A),
) { ) {
Column( Column(
modifier = Modifier.padding(12.dp), modifier = Modifier.padding(12.dp),

View File

@ -1,7 +1,10 @@
package com.clawdbot.android.ui.chat package com.clawdbot.android.ui.chat
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentValues
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Base64 import android.util.Base64
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -14,8 +17,10 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -52,7 +57,9 @@ fun ChatSheetContent(viewModel: MainViewModel) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val attachments = remember { mutableStateListOf<PendingImageAttachment>() } val attachments = remember { mutableStateListOf<PendingImageAttachment>() }
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
// Image picker
val pickImages = val pickImages =
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
if (uris.isNullOrEmpty()) return@rememberLauncherForActivityResult if (uris.isNullOrEmpty()) return@rememberLauncherForActivityResult
@ -60,7 +67,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
val next = val next =
uris.take(8).mapNotNull { uri -> uris.take(8).mapNotNull { uri ->
try { try {
loadImageAttachment(resolver, uri) loadAttachment(resolver, uri)
} catch (_: Throwable) { } catch (_: Throwable) {
null 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( Column(
modifier = modifier =
Modifier Modifier
@ -97,9 +152,9 @@ fun ChatSheetContent(viewModel: MainViewModel) {
attachments = attachments, attachments = attachments,
seamColor = seamColor, seamColor = seamColor,
talkEnabled = talkEnabled, talkEnabled = talkEnabled,
onOpenCamera = { /* TODO: Implement camera capture */ }, onOpenCamera = { openCamera() },
onPickImages = { pickImages.launch("image/*") }, onPickImages = { pickImages.launch("image/*") },
onPickFiles = { /* TODO: Implement file picker */ }, onPickFiles = { pickFiles.launch(arrayOf("*/*")) },
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } }, onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) }, onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
onSelectSession = { key -> viewModel.switchChatSession(key) }, onSelectSession = { key -> viewModel.switchChatSession(key) },
@ -112,8 +167,12 @@ fun ChatSheetContent(viewModel: MainViewModel) {
onSend = { text -> onSend = { text ->
val outgoing = val outgoing =
attachments.map { att -> attachments.map { att ->
val type = when {
att.mimeType.startsWith("image/") -> "image"
else -> "file"
}
OutgoingAttachment( OutgoingAttachment(
type = "image", type = type,
mimeType = att.mimeType, mimeType = att.mimeType,
fileName = att.fileName, fileName = att.fileName,
base64 = att.base64, base64 = att.base64,
@ -133,9 +192,9 @@ data class PendingImageAttachment(
val base64: String, val base64: String,
) )
private suspend fun loadImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment { private suspend fun loadAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment {
val mimeType = resolver.getType(uri) ?: "image/*" val mimeType = resolver.getType(uri) ?: "application/octet-stream"
val fileName = (uri.lastPathSegment ?: "image").substringAfterLast('/') val fileName = (uri.lastPathSegment ?: "file").substringAfterLast('/')
val bytes = val bytes =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
resolver.openInputStream(uri)?.use { input -> resolver.openInputStream(uri)?.use { input ->