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:
parent
5c54b9b9e3
commit
09489318bf
3 changed files with 66 additions and 18 deletions
|
|
@ -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))
|
||||||
ChatInput(
|
Box {
|
||||||
value = input,
|
ChatInput(
|
||||||
onValueChange = { input = it },
|
value = input,
|
||||||
onSend = { send(input) },
|
onValueChange = { input = it },
|
||||||
enabled = !isStreaming,
|
onSend = { send(input) },
|
||||||
pendingFiles = pendingFiles,
|
enabled = !isStreaming,
|
||||||
onAddClick = { filePicker.launch("*/*") },
|
pendingFiles = pendingFiles,
|
||||||
onRemoveFile = { id -> pendingFiles.removeAll { it.id == id } },
|
onAddClick = { showAttachMenu = true },
|
||||||
)
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 & 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>
|
||||||
|
|
|
||||||
|
|
@ -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 & 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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue