wip
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
package com.mycrib.platform
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@Composable
|
||||
actual fun rememberImagePicker(
|
||||
onImagesPicked: (List<ImageData>) -> Unit
|
||||
): () -> Unit {
|
||||
val context = LocalContext.current
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.PickMultipleVisualMedia(5)
|
||||
) { uris: List<Uri> ->
|
||||
if (uris.isNotEmpty()) {
|
||||
val images = uris.mapNotNull { uri ->
|
||||
try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
val bytes = inputStream?.readBytes()
|
||||
inputStream?.close()
|
||||
|
||||
val fileName = getFileNameFromUri(context, uri)
|
||||
|
||||
if (bytes != null) {
|
||||
ImageData(bytes, fileName)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
if (images.isNotEmpty()) {
|
||||
onImagesPicked(images)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
launcher.launch(
|
||||
PickVisualMediaRequest(
|
||||
ActivityResultContracts.PickVisualMedia.ImageOnly
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFileNameFromUri(context: android.content.Context, uri: Uri): String {
|
||||
var fileName = "image_${System.currentTimeMillis()}.jpg"
|
||||
|
||||
try {
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||
if (nameIndex >= 0) {
|
||||
val name = cursor.getString(nameIndex)
|
||||
if (name != null) {
|
||||
fileName = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
return fileName
|
||||
}
|
||||
@@ -93,4 +93,52 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createCompletionWithImages(
|
||||
token: String,
|
||||
request: TaskCompletionCreateRequest,
|
||||
images: List<ByteArray> = emptyList(),
|
||||
imageFileNames: List<String> = emptyList()
|
||||
): ApiResult<TaskCompletion> {
|
||||
return try {
|
||||
val response = client.post("$baseUrl/task-completions/") {
|
||||
header("Authorization", "Token $token")
|
||||
|
||||
setBody(
|
||||
io.ktor.client.request.forms.MultiPartFormDataContent(
|
||||
io.ktor.client.request.forms.formData {
|
||||
// Add text fields
|
||||
append("task", request.task.toString())
|
||||
request.completedByName?.let { append("completed_by_name", it) }
|
||||
append("completion_date", request.completionDate)
|
||||
request.actualCost?.let { append("actual_cost", it) }
|
||||
request.notes?.let { append("notes", it) }
|
||||
request.rating?.let { append("rating", it.toString()) }
|
||||
|
||||
// Add image files
|
||||
images.forEachIndexed { index, imageBytes ->
|
||||
val fileName = imageFileNames.getOrNull(index) ?: "image_$index.jpg"
|
||||
append(
|
||||
"images",
|
||||
imageBytes,
|
||||
io.ktor.http.Headers.build {
|
||||
append(HttpHeaders.ContentType, "image/jpeg")
|
||||
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(response.body())
|
||||
} else {
|
||||
ApiResult.Error("Failed to create completion with images", response.status.value)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.mycrib.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
data class ImageData(
|
||||
val bytes: ByteArray,
|
||||
val fileName: String
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || this::class != other::class) return false
|
||||
|
||||
other as ImageData
|
||||
|
||||
if (!bytes.contentEquals(other.bytes)) return false
|
||||
if (fileName != other.fileName) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = bytes.contentHashCode()
|
||||
result = 31 * result + fileName.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun rememberImagePicker(
|
||||
onImagesPicked: (List<ImageData>) -> Unit
|
||||
): () -> Unit
|
||||
@@ -4,14 +4,17 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mycrib.shared.models.TaskCompletionCreateRequest
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
import com.mycrib.platform.ImageData
|
||||
import com.mycrib.platform.rememberImagePicker
|
||||
import kotlinx.datetime.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -19,12 +22,17 @@ fun CompleteTaskDialog(
|
||||
taskId: Int,
|
||||
taskTitle: String,
|
||||
onDismiss: () -> Unit,
|
||||
onComplete: (TaskCompletionCreateRequest) -> Unit
|
||||
onComplete: (TaskCompletionCreateRequest, List<ImageData>) -> Unit
|
||||
) {
|
||||
var completedByName by remember { mutableStateOf("") }
|
||||
var actualCost by remember { mutableStateOf("") }
|
||||
var notes by remember { mutableStateOf("") }
|
||||
var rating by remember { mutableStateOf(3) }
|
||||
var selectedImages by remember { mutableStateOf<List<ImageData>>(emptyList()) }
|
||||
|
||||
val imagePicker = rememberImagePicker { images ->
|
||||
selectedImages = images
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -72,6 +80,57 @@ fun CompleteTaskDialog(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
// Image upload section
|
||||
Column {
|
||||
Text(
|
||||
text = "Add Images",
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { imagePicker() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Select Images (up to 5)")
|
||||
}
|
||||
|
||||
// Display selected images
|
||||
if (selectedImages.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "${selectedImages.size} image(s) selected",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
selectedImages.forEach { image ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = image.fileName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
selectedImages = selectedImages.filter { it != image }
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove image",
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
@@ -88,7 +147,8 @@ fun CompleteTaskDialog(
|
||||
actualCost = actualCost.ifBlank { null },
|
||||
notes = notes.ifBlank { null },
|
||||
rating = rating
|
||||
)
|
||||
),
|
||||
selectedImages
|
||||
)
|
||||
}
|
||||
) {
|
||||
@@ -104,8 +164,6 @@ fun CompleteTaskDialog(
|
||||
}
|
||||
|
||||
// Helper function to get current date/time in ISO format
|
||||
@OptIn(ExperimentalTime::class)
|
||||
private fun getCurrentDateTime(): String {
|
||||
val now = kotlin.time.Clock.System.now()
|
||||
return now.toString()
|
||||
return kotlinx.datetime.LocalDate.toString()
|
||||
}
|
||||
|
||||
@@ -69,8 +69,18 @@ fun ResidenceDetailScreen(
|
||||
selectedTask = null
|
||||
taskCompletionViewModel.resetCreateState()
|
||||
},
|
||||
onComplete = { request ->
|
||||
taskCompletionViewModel.createTaskCompletion(request)
|
||||
onComplete = { request, images ->
|
||||
if (images.isNotEmpty()) {
|
||||
// Use the method that supports images
|
||||
taskCompletionViewModel.createTaskCompletionWithImages(
|
||||
request = request,
|
||||
images = images.map { it.bytes },
|
||||
imageFileNames = images.map { it.fileName }
|
||||
)
|
||||
} else {
|
||||
// Use the regular method without images
|
||||
taskCompletionViewModel.createTaskCompletion(request)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,34 @@ class TaskCompletionViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create task completion with images.
|
||||
*
|
||||
* @param request The completion request data
|
||||
* @param images List of image data as ByteArray (from platform-specific image pickers)
|
||||
* @param imageFileNames Optional list of file names for the images
|
||||
*/
|
||||
fun createTaskCompletionWithImages(
|
||||
request: TaskCompletionCreateRequest,
|
||||
images: List<ByteArray> = emptyList(),
|
||||
imageFileNames: List<String> = emptyList()
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_createCompletionState.value = ApiResult.Loading
|
||||
val token = TokenStorage.getToken()
|
||||
if (token != null) {
|
||||
_createCompletionState.value = taskCompletionApi.createCompletionWithImages(
|
||||
token = token,
|
||||
request = request,
|
||||
images = images,
|
||||
imageFileNames = imageFileNames
|
||||
)
|
||||
} else {
|
||||
_createCompletionState.value = ApiResult.Error("Not authenticated", 401)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetCreateState() {
|
||||
_createCompletionState.value = ApiResult.Loading
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.mycrib.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.interop.LocalUIViewController
|
||||
import kotlinx.cinterop.*
|
||||
import platform.Foundation.*
|
||||
import platform.PhotosUI.*
|
||||
import platform.UIKit.*
|
||||
import platform.darwin.NSObject
|
||||
import platform.posix.memcpy
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
@Composable
|
||||
actual fun rememberImagePicker(
|
||||
onImagesPicked: (List<ImageData>) -> Unit
|
||||
): () -> Unit {
|
||||
val viewController = LocalUIViewController.current
|
||||
|
||||
val pickerDelegate = remember {
|
||||
object : NSObject(), PHPickerViewControllerDelegateProtocol {
|
||||
override fun picker(
|
||||
picker: PHPickerViewController,
|
||||
didFinishPicking: List<*>
|
||||
) {
|
||||
picker.dismissViewControllerAnimated(true, null)
|
||||
|
||||
val results = didFinishPicking as List<PHPickerResult>
|
||||
if (results.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val images = mutableListOf<ImageData>()
|
||||
var processedCount = 0
|
||||
|
||||
results.forEach { result ->
|
||||
val itemProvider = result.itemProvider
|
||||
|
||||
// Check if the item has an image using UTType
|
||||
if (itemProvider.hasItemConformingToTypeIdentifier("public.image")) {
|
||||
itemProvider.loadFileRepresentationForTypeIdentifier(
|
||||
typeIdentifier = "public.image",
|
||||
completionHandler = { url, error ->
|
||||
if (error == null && url != null) {
|
||||
// Read the image data from the file URL
|
||||
val imageData = NSData.dataWithContentsOfURL(url)
|
||||
if (imageData != null) {
|
||||
// Convert to UIImage and then to JPEG for consistent format
|
||||
val image = UIImage.imageWithData(imageData)
|
||||
if (image != null) {
|
||||
val jpegData = UIImageJPEGRepresentation(image, 0.8)
|
||||
if (jpegData != null) {
|
||||
val bytes = jpegData.toByteArray()
|
||||
val fileName = "image_${NSDate().timeIntervalSince1970.toLong()}_${processedCount}.jpg"
|
||||
synchronized(images) {
|
||||
images.add(ImageData(bytes, fileName))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processedCount++
|
||||
|
||||
// When all images are processed, call the callback
|
||||
if (processedCount == results.size) {
|
||||
if (images.isNotEmpty()) {
|
||||
onImagesPicked(images.toList())
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
processedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
val config = PHPickerConfiguration().apply {
|
||||
setSelectionLimit(5)
|
||||
setFilter(PHPickerFilter.imagesFilter())
|
||||
}
|
||||
|
||||
val picker = PHPickerViewController(config).apply {
|
||||
setDelegate(pickerDelegate)
|
||||
}
|
||||
|
||||
viewController.presentViewController(picker, animated = true, completion = null)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun NSData.toByteArray(): ByteArray {
|
||||
return ByteArray(length.toInt()).apply {
|
||||
usePinned {
|
||||
memcpy(it.addressOf(0), bytes, length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T : Any> synchronized(lock: Any, block: () -> T): T {
|
||||
return block()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.mycrib.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
actual fun rememberImagePicker(
|
||||
onImagesPicked: (List<ImageData>) -> Unit
|
||||
): () -> Unit {
|
||||
// Web image picker would require HTML5 file input
|
||||
// This is a placeholder implementation
|
||||
return {
|
||||
// TODO: Implement web file input
|
||||
println("Image picker not yet implemented for web")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.mycrib.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
actual fun rememberImagePicker(
|
||||
onImagesPicked: (List<ImageData>) -> Unit
|
||||
): () -> Unit {
|
||||
// Desktop image picker would require platform-specific file chooser
|
||||
// This is a placeholder implementation
|
||||
return {
|
||||
// TODO: Implement desktop file chooser
|
||||
println("Image picker not yet implemented for desktop")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.mycrib.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
actual fun rememberImagePicker(
|
||||
onImagesPicked: (List<ImageData>) -> Unit
|
||||
): () -> Unit {
|
||||
// WASM image picker would require HTML5 file input
|
||||
// This is a placeholder implementation
|
||||
return {
|
||||
// TODO: Implement WASM file input
|
||||
println("Image picker not yet implemented for WASM")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user