From 177e588944310879e605e5a3db7ceb75f7573803 Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 4 Nov 2025 16:34:05 -0600 Subject: [PATCH] wip --- composeApp/build.gradle.kts | 1 + .../mycrib/platform/ImagePicker.android.kt | 73 ++++++++++++ .../mycrib/network/TaskCompletionApi.kt | 48 ++++++++ .../example/mycrib/platform/ImagePicker.kt | 31 +++++ .../ui/components/CompleteTaskDialog.kt | 72 ++++++++++-- .../ui/screens/ResidenceDetailScreen.kt | 14 ++- .../viewmodel/TaskCompletionViewModel.kt | 28 +++++ .../mycrib/platform/ImagePicker.ios.kt | 107 ++++++++++++++++++ .../example/mycrib/platform/ImagePicker.js.kt | 15 +++ .../mycrib/platform/ImagePicker.jvm.kt | 15 +++ .../mycrib/platform/ImagePicker.wasmJs.kt | 15 +++ 11 files changed, 410 insertions(+), 9 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/example/mycrib/platform/ImagePicker.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/platform/ImagePicker.kt create mode 100644 composeApp/src/iosMain/kotlin/com/example/mycrib/platform/ImagePicker.ios.kt create mode 100644 composeApp/src/jsMain/kotlin/com/example/mycrib/platform/ImagePicker.js.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/example/mycrib/platform/ImagePicker.jvm.kt create mode 100644 composeApp/src/wasmJsMain/kotlin/com/example/mycrib/platform/ImagePicker.wasmJs.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index ddd883e..9903339 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -81,6 +81,7 @@ kotlin { implementation(libs.kotlinx.datetime) implementation(libs.ktor.client.logging) implementation(compose.materialIconsExtended) + implementation("org.jetbrains.kotlinx:kotlinx-datetime:") } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/composeApp/src/androidMain/kotlin/com/example/mycrib/platform/ImagePicker.android.kt b/composeApp/src/androidMain/kotlin/com/example/mycrib/platform/ImagePicker.android.kt new file mode 100644 index 0000000..6593611 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/mycrib/platform/ImagePicker.android.kt @@ -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) -> Unit +): () -> Unit { + val context = LocalContext.current + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickMultipleVisualMedia(5) + ) { uris: List -> + 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 +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskCompletionApi.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskCompletionApi.kt index 7c580e5..413f6b1 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskCompletionApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/TaskCompletionApi.kt @@ -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 = emptyList(), + imageFileNames: List = emptyList() + ): ApiResult { + 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") + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/platform/ImagePicker.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/platform/ImagePicker.kt new file mode 100644 index 0000000..8dc9835 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/platform/ImagePicker.kt @@ -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) -> Unit +): () -> Unit diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/CompleteTaskDialog.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/CompleteTaskDialog.kt index 685534b..543df2d 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/CompleteTaskDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/CompleteTaskDialog.kt @@ -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) -> 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>(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() } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt index a00a6e2..d36a0c4 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt @@ -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) + } } ) } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskCompletionViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskCompletionViewModel.kt index 962cb75..10ffc2c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskCompletionViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskCompletionViewModel.kt @@ -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 = emptyList(), + imageFileNames: List = 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 } diff --git a/composeApp/src/iosMain/kotlin/com/example/mycrib/platform/ImagePicker.ios.kt b/composeApp/src/iosMain/kotlin/com/example/mycrib/platform/ImagePicker.ios.kt new file mode 100644 index 0000000..1486e0e --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/example/mycrib/platform/ImagePicker.ios.kt @@ -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) -> 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 + if (results.isEmpty()) { + return + } + + val images = mutableListOf() + 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 synchronized(lock: Any, block: () -> T): T { + return block() +} diff --git a/composeApp/src/jsMain/kotlin/com/example/mycrib/platform/ImagePicker.js.kt b/composeApp/src/jsMain/kotlin/com/example/mycrib/platform/ImagePicker.js.kt new file mode 100644 index 0000000..bdd0a8d --- /dev/null +++ b/composeApp/src/jsMain/kotlin/com/example/mycrib/platform/ImagePicker.js.kt @@ -0,0 +1,15 @@ +package com.mycrib.platform + +import androidx.compose.runtime.Composable + +@Composable +actual fun rememberImagePicker( + onImagesPicked: (List) -> 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") + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/example/mycrib/platform/ImagePicker.jvm.kt b/composeApp/src/jvmMain/kotlin/com/example/mycrib/platform/ImagePicker.jvm.kt new file mode 100644 index 0000000..3ed8520 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/example/mycrib/platform/ImagePicker.jvm.kt @@ -0,0 +1,15 @@ +package com.mycrib.platform + +import androidx.compose.runtime.Composable + +@Composable +actual fun rememberImagePicker( + onImagesPicked: (List) -> 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") + } +} diff --git a/composeApp/src/wasmJsMain/kotlin/com/example/mycrib/platform/ImagePicker.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/example/mycrib/platform/ImagePicker.wasmJs.kt new file mode 100644 index 0000000..bb76d91 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/com/example/mycrib/platform/ImagePicker.wasmJs.kt @@ -0,0 +1,15 @@ +package com.mycrib.platform + +import androidx.compose.runtime.Composable + +@Composable +actual fun rememberImagePicker( + onImagesPicked: (List) -> 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") + } +}