diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/TaskCompletion.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/TaskCompletion.kt index 76f7a82..3983a92 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/TaskCompletion.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/TaskCompletion.kt @@ -13,8 +13,7 @@ data class TaskCompletionCreateRequest( val notes: String? = null, @SerialName("actual_cost") val actualCost: Double? = null, val rating: Int? = null, // 1-5 star rating - @SerialName("image_urls") val imageUrls: List? = null, // Legacy: URLs returned by /api/uploads/* multipart endpoints - @SerialName("upload_ids") val uploadIds: List? = null // New: pending_uploads.id values from /api/uploads/presign + direct B2 POST + @SerialName("upload_ids") val uploadIds: List? = null // pending_uploads.id values from /api/uploads/presign + direct B2 POST ) /** diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt index e4088b0..71587f9 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt @@ -784,30 +784,6 @@ object APILayer { } } - suspend fun createTaskCompletionWithImages( - request: TaskCompletionCreateRequest, - images: List, - imageFileNames: List - ): ApiResult { - val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) - val result = taskCompletionApi.createCompletionWithImages(token, request, images, imageFileNames) - - if (result is ApiResult.Success) { - // Update summary from response - eliminates need for separate getSummary call - DataManager.setTotalSummary(result.data.summary) - // The response includes the updated task, update it in DataManager - result.data.data.updatedTask?.let { updatedTask -> - DataManager.updateTask(updatedTask) - } - return ApiResult.Success(result.data.data) - } - - return when (result) { - is ApiResult.Error -> result - else -> ApiResult.Error("Unknown error") - } - } - /** * Get all completions for a specific task */ diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskCompletionApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskCompletionApi.kt index a6a972f..7af3129 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskCompletionApi.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/TaskCompletionApi.kt @@ -94,47 +94,4 @@ class TaskCompletionApi(private val client: HttpClient = ApiClient.httpClient) { } } - suspend fun createCompletionWithImages( - token: String, - request: TaskCompletionCreateRequest, - images: List = emptyList(), - imageFileNames: List = emptyList() - ): ApiResult> { - return try { - val response = client.submitFormWithBinaryData( - url = "$baseUrl/task-completions/", - formData = formData { - // Add text fields - append("task_id", request.taskId.toString()) - request.completedAt?.let { append("completed_at", it) } - request.actualCost?.let { append("actual_cost", it.toString()) } - 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, - Headers.build { - append(HttpHeaders.ContentType, "image/jpeg") - append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"") - } - ) - } - } - ) { - header("Authorization", "Token $token") - } - - 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/tt/honeyDue/ui/components/CompleteTaskDialog.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/CompleteTaskDialog.kt index 4c8703d..b1cce0d 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/CompleteTaskDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/CompleteTaskDialog.kt @@ -373,7 +373,8 @@ fun CompleteTaskDialog( actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(), notes = notesWithContractor, rating = rating, - imageUrls = null // Images uploaded separately and URLs added by handler + // upload_ids populated by the ViewModel after each + // image is uploaded directly to B2. ), selectedImages ) diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/CompleteTaskScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/CompleteTaskScreen.kt index ff9516b..20a0eab 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/CompleteTaskScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/CompleteTaskScreen.kt @@ -405,7 +405,8 @@ fun CompleteTaskScreen( actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(), notes = notesWithContractor, rating = rating, - imageUrls = null + // upload_ids populated by the ViewModel after each + // image is uploaded directly to B2. ), selectedImages ) diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/TaskCompletionViewModel.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/TaskCompletionViewModel.kt index d18a228..72145bc 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/TaskCompletionViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/TaskCompletionViewModel.kt @@ -25,38 +25,61 @@ class TaskCompletionViewModel : ViewModel() { } /** - * Create task completion with images. + * Create task completion with images, using the presigned-URL upload flow. + * + * For each image: compress, presign + POST direct to B2, collect the + * upload_id. Once all uploads succeed, create the completion with the + * collected upload_ids in a single JSON request. Bytes never traverse + * our API server. + * + * If any individual upload fails, the whole batch fails — partial + * pending_uploads rows are reaped server-side by the hourly cleanup + * cron, so there's nothing to clean up client-side. * * @param request The completion request data * @param images List of ImageData (from platform-specific image pickers) */ fun createTaskCompletionWithImages( request: TaskCompletionCreateRequest, - images: List = emptyList() + images: List = emptyList(), ) { viewModelScope.launch { _createCompletionState.value = ApiResult.Loading - // Compress images and prepare for upload - val compressedImages = images.map { ImageCompressor.compressImage(it) } - val imageFileNames = images.mapIndexed { index, image -> - // Always use .jpg extension since we compress to JPEG - val baseName = image.fileName.ifBlank { "completion_$index" } - if (baseName.endsWith(".jpg", ignoreCase = true) || - baseName.endsWith(".jpeg", ignoreCase = true)) { - baseName - } else { - // Remove any existing extension and add .jpg - baseName.substringBeforeLast('.', baseName) + ".jpg" + val uploadIds = mutableListOf() + for ((index, image) in images.withIndex()) { + val compressed = ImageCompressor.compressImage(image) + val fileName = run { + val base = image.fileName.ifBlank { "completion_$index" } + if (base.endsWith(".jpg", ignoreCase = true) || + base.endsWith(".jpeg", ignoreCase = true) + ) base else base.substringBeforeLast('.', base) + ".jpg" + } + val uploadResult = APILayer.uploadImage( + category = "completion", + contentType = "image/jpeg", + bytes = compressed, + fileName = fileName, + ) + when (uploadResult) { + is ApiResult.Success -> uploadIds += uploadResult.data + is ApiResult.Error -> { + _createCompletionState.value = ApiResult.Error(uploadResult.message, uploadResult.code) + return@launch + } + else -> { + _createCompletionState.value = ApiResult.Error("Upload failed in unexpected state") + return@launch + } } } - // Use APILayer which handles DataManager updates and summary refresh - _createCompletionState.value = APILayer.createTaskCompletionWithImages( - request = request, - images = compressedImages, - imageFileNames = imageFileNames - ) + val withUploads = if (uploadIds.isNotEmpty()) { + request.copy(uploadIds = uploadIds.toList()) + } else { + request + } + _createCompletionState.value = APILayer.createTaskCompletion(withUploads) } } diff --git a/iosApp/iosApp/Helpers/WidgetActionProcessor.swift b/iosApp/iosApp/Helpers/WidgetActionProcessor.swift index 0e2f509..a4c6df6 100644 --- a/iosApp/iosApp/Helpers/WidgetActionProcessor.swift +++ b/iosApp/iosApp/Helpers/WidgetActionProcessor.swift @@ -64,7 +64,6 @@ final class WidgetActionProcessor { notes: "Completed from widget", actualCost: nil, rating: nil, - imageUrls: nil, uploadIds: nil ) diff --git a/iosApp/iosApp/PushNotifications/PushNotificationManager.swift b/iosApp/iosApp/PushNotifications/PushNotificationManager.swift index 2c65e60..9953fa4 100644 --- a/iosApp/iosApp/PushNotifications/PushNotificationManager.swift +++ b/iosApp/iosApp/PushNotifications/PushNotificationManager.swift @@ -388,7 +388,6 @@ class PushNotificationManager: NSObject, ObservableObject { notes: nil, actualCost: nil, rating: nil, - imageUrls: nil, uploadIds: nil ) let result = try await APILayer.shared.createTaskCompletion(request: request) diff --git a/iosApp/iosApp/Task/CompleteTaskView.swift b/iosApp/iosApp/Task/CompleteTaskView.swift index 2afb50d..6293d0b 100644 --- a/iosApp/iosApp/Task/CompleteTaskView.swift +++ b/iosApp/iosApp/Task/CompleteTaskView.swift @@ -351,7 +351,6 @@ struct CompleteTaskView: View { notes: notes.isEmpty ? nil : notes, actualCost: actualCost.isEmpty ? nil : KotlinDouble(double: Double(actualCost) ?? 0.0), rating: KotlinInt(int: Int32(rating)), - imageUrls: nil, uploadIds: nil ) completionViewModel.createTaskCompletion(request: request) @@ -416,7 +415,6 @@ struct CompleteTaskView: View { notes: notes.isEmpty ? nil : notes, actualCost: actualCost.isEmpty ? nil : KotlinDouble(double: Double(actualCost) ?? 0.0), rating: KotlinInt(int: Int32(rating)), - imageUrls: nil, uploadIds: uploadIds.map { KotlinInt(int: $0) } ) await MainActor.run {