wip
This commit is contained in:
@@ -81,6 +81,7 @@ kotlin {
|
|||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
implementation(libs.ktor.client.logging)
|
implementation(libs.ktor.client.logging)
|
||||||
implementation(compose.materialIconsExtended)
|
implementation(compose.materialIconsExtended)
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-datetime:<latest-version>")
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(libs.kotlin.test)
|
implementation(libs.kotlin.test)
|
||||||
|
|||||||
@@ -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")
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
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.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.mycrib.shared.models.TaskCompletionCreateRequest
|
import com.mycrib.shared.models.TaskCompletionCreateRequest
|
||||||
import kotlinx.datetime.Clock
|
import com.mycrib.platform.ImageData
|
||||||
import kotlin.time.ExperimentalTime
|
import com.mycrib.platform.rememberImagePicker
|
||||||
|
import kotlinx.datetime.*
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -19,12 +22,17 @@ fun CompleteTaskDialog(
|
|||||||
taskId: Int,
|
taskId: Int,
|
||||||
taskTitle: String,
|
taskTitle: String,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onComplete: (TaskCompletionCreateRequest) -> Unit
|
onComplete: (TaskCompletionCreateRequest, List<ImageData>) -> Unit
|
||||||
) {
|
) {
|
||||||
var completedByName by remember { mutableStateOf("") }
|
var completedByName by remember { mutableStateOf("") }
|
||||||
var actualCost by remember { mutableStateOf("") }
|
var actualCost by remember { mutableStateOf("") }
|
||||||
var notes by remember { mutableStateOf("") }
|
var notes by remember { mutableStateOf("") }
|
||||||
var rating by remember { mutableStateOf(3) }
|
var rating by remember { mutableStateOf(3) }
|
||||||
|
var selectedImages by remember { mutableStateOf<List<ImageData>>(emptyList()) }
|
||||||
|
|
||||||
|
val imagePicker = rememberImagePicker { images ->
|
||||||
|
selectedImages = images
|
||||||
|
}
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -72,6 +80,57 @@ fun CompleteTaskDialog(
|
|||||||
modifier = Modifier.fillMaxWidth()
|
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 = {
|
confirmButton = {
|
||||||
@@ -88,7 +147,8 @@ fun CompleteTaskDialog(
|
|||||||
actualCost = actualCost.ifBlank { null },
|
actualCost = actualCost.ifBlank { null },
|
||||||
notes = notes.ifBlank { null },
|
notes = notes.ifBlank { null },
|
||||||
rating = rating
|
rating = rating
|
||||||
)
|
),
|
||||||
|
selectedImages
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
@@ -104,8 +164,6 @@ fun CompleteTaskDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get current date/time in ISO format
|
// Helper function to get current date/time in ISO format
|
||||||
@OptIn(ExperimentalTime::class)
|
|
||||||
private fun getCurrentDateTime(): String {
|
private fun getCurrentDateTime(): String {
|
||||||
val now = kotlin.time.Clock.System.now()
|
return kotlinx.datetime.LocalDate.toString()
|
||||||
return now.toString()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,9 +69,19 @@ fun ResidenceDetailScreen(
|
|||||||
selectedTask = null
|
selectedTask = null
|
||||||
taskCompletionViewModel.resetCreateState()
|
taskCompletionViewModel.resetCreateState()
|
||||||
},
|
},
|
||||||
onComplete = { 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)
|
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() {
|
fun resetCreateState() {
|
||||||
_createCompletionState.value = ApiResult.Loading
|
_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