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:
parent
fbf18d55b8
commit
3e57ca5d72
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 ->
|
||||
|
||||
Loading…
Reference in New Issue
Block a user