feat: attachment picker menu — camera, gallery, and file options

Replaced the generic file picker with a dropdown menu offering three
options: take photo (camera preview), pick from gallery (image/*), or
choose any file (*/*). Camera captures are compressed as JPEG and
uploaded like any other file. Upload logic extracted into shared
uploadPickedFile helper.
This commit is contained in:
Bruno Deanoz 2026-06-21 18:45:41 +02:00
parent 5c54b9b9e3
commit 09489318bf
3 changed files with 66 additions and 18 deletions

View file

@ -72,10 +72,17 @@ import dev.kaizen.app.settings.SettingsScreen
import dev.kaizen.app.settings.SettingsViewModel import dev.kaizen.app.settings.SettingsViewModel
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.rounded.CameraAlt
import androidx.compose.material.icons.rounded.Image
import androidx.compose.material.icons.automirrored.rounded.InsertDriveFile
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream
// Screen enumeration for modular state-driven navigation (Zero Technical Debt) // Screen enumeration for modular state-driven navigation (Zero Technical Debt)
enum class AppScreen { Chat, Settings } enum class AppScreen { Chat, Settings }
@ -123,17 +130,14 @@ fun ChatScreen(
val pendingFiles = remember { mutableStateListOf<PendingFile>() } val pendingFiles = remember { mutableStateListOf<PendingFile>() }
val context = LocalContext.current val context = LocalContext.current
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> var showAttachMenu by remember { mutableStateOf(false) }
uri ?: return@rememberLauncherForActivityResult
val cfg = session.config ?: return@rememberLauncherForActivityResult fun uploadPickedFile(name: String, mimeType: String, bytes: ByteArray) {
val cr = context.contentResolver val cfg = session.config ?: return
val mimeType = cr.getType(uri) ?: "application/octet-stream" val pf = PendingFile(name = name, mimeType = mimeType, bytes = bytes)
val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "file"
val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: return@rememberLauncherForActivityResult
val pf = PendingFile(name = fileName, mimeType = mimeType, bytes = bytes)
pendingFiles.add(pf) pendingFiles.add(pf)
scope.launch { scope.launch {
when (val r = KaizenApi.uploadFile(cfg.baseUrl, cfg.token, fileName, mimeType, bytes)) { when (val r = KaizenApi.uploadFile(cfg.baseUrl, cfg.token, name, mimeType, bytes)) {
is FetchResult.Ok -> { is FetchResult.Ok -> {
val idx = pendingFiles.indexOfFirst { it.id == pf.id } val idx = pendingFiles.indexOfFirst { it.id == pf.id }
if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(uploaded = r.data, uploading = false) if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(uploaded = r.data, uploading = false)
@ -146,6 +150,22 @@ fun ChatScreen(
} }
} }
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri ?: return@rememberLauncherForActivityResult
val cr = context.contentResolver
val mimeType = cr.getType(uri) ?: "application/octet-stream"
val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "file"
val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: return@rememberLauncherForActivityResult
uploadPickedFile(fileName, mimeType, bytes)
}
val cameraPicker = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap ->
bitmap ?: return@rememberLauncherForActivityResult
val stream = ByteArrayOutputStream()
bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 92, stream)
uploadPickedFile("photo_${System.currentTimeMillis()}.jpg", "image/jpeg", stream.toByteArray())
}
LaunchedEffect(session.config?.baseUrl, session.config?.token) { LaunchedEffect(session.config?.baseUrl, session.config?.token) {
val cfg = session.config ?: return@LaunchedEffect val cfg = session.config ?: return@LaunchedEffect
loadError = null loadError = null
@ -525,15 +545,37 @@ fun ChatScreen(
}, },
) )
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
Box {
ChatInput( ChatInput(
value = input, value = input,
onValueChange = { input = it }, onValueChange = { input = it },
onSend = { send(input) }, onSend = { send(input) },
enabled = !isStreaming, enabled = !isStreaming,
pendingFiles = pendingFiles, pendingFiles = pendingFiles,
onAddClick = { filePicker.launch("*/*") }, onAddClick = { showAttachMenu = true },
onRemoveFile = { id -> pendingFiles.removeAll { it.id == id } }, onRemoveFile = { id -> pendingFiles.removeAll { it.id == id } },
) )
DropdownMenu(
expanded = showAttachMenu,
onDismissRequest = { showAttachMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.attach_camera)) },
onClick = { showAttachMenu = false; cameraPicker.launch(null) },
leadingIcon = { Icon(Icons.Rounded.CameraAlt, null, modifier = Modifier.size(20.dp)) },
)
DropdownMenuItem(
text = { Text(stringResource(R.string.attach_gallery)) },
onClick = { showAttachMenu = false; filePicker.launch("image/*") },
leadingIcon = { Icon(Icons.Rounded.Image, null, modifier = Modifier.size(20.dp)) },
)
DropdownMenuItem(
text = { Text(stringResource(R.string.attach_file)) },
onClick = { showAttachMenu = false; filePicker.launch("*/*") },
leadingIcon = { Icon(Icons.AutoMirrored.Rounded.InsertDriveFile, null, modifier = Modifier.size(20.dp)) },
)
}
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
} }

View file

@ -18,6 +18,9 @@
<string name="chat_remove">Remove</string> <string name="chat_remove">Remove</string>
<string name="chat_error">Error</string> <string name="chat_error">Error</string>
<string name="chat_open_menu">Open menu</string> <string name="chat_open_menu">Open menu</string>
<string name="attach_camera">Take Photo</string>
<string name="attach_gallery">Photo &amp; Video</string>
<string name="attach_file">Choose File</string>
<!-- Stream blocks --> <!-- Stream blocks -->
<string name="stream_reasoning">Reasoning</string> <string name="stream_reasoning">Reasoning</string>

View file

@ -20,6 +20,9 @@
<string name="chat_remove">Entfernen</string> <string name="chat_remove">Entfernen</string>
<string name="chat_error">Fehler</string> <string name="chat_error">Fehler</string>
<string name="chat_open_menu">Menü öffnen</string> <string name="chat_open_menu">Menü öffnen</string>
<string name="attach_camera">Foto aufnehmen</string>
<string name="attach_gallery">Foto &amp; Video</string>
<string name="attach_file">Datei auswählen</string>
<!-- Stream blocks --> <!-- Stream blocks -->
<string name="stream_reasoning">Gedankengang</string> <string name="stream_reasoning">Gedankengang</string>