This commit is contained in:
Trey t
2025-11-04 16:34:05 -06:00
parent 3e617c9cd8
commit 177e588944
11 changed files with 410 additions and 9 deletions

View File

@@ -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
}

View File

@@ -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")
}
}
}

View File

@@ -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

View File

@@ -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()
}

View File

@@ -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)
}
}
)
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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")
}
}

View File

@@ -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")
}
}

View File

@@ -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")
}
}