feat(uploads): direct-to-B2 presigned image upload from iOS + Android
iOS (Swift) — primary path, since iOS is the live platform:
- ImageDownsampler.swift: ImageIO/CGImageSourceCreateThumbnailAtIndex
based resize. Pays only the cost of the resized bitmap rather than
decoding the full source — a 12 MP iPhone photo previously
materialized ~50 MB regardless of JPEG size. Profiles: completion
(2048 px / quality 0.85), document_image (2560 px / 0.90).
- PresignedUploader.swift: three-step orchestration (POST /uploads/presign
→ multipart POST direct to B2 with the signed policy fields → return
upload_id). Maps HTTP errors to user-facing copy. Concurrent uploads
via TaskGroup.
- CompleteTaskView.swift: replaces the multipart-with-images path with
downsample → upload-to-B2 → create-completion-with-upload_ids[]. The
no-image branch unchanged.
Android (Kotlin) — parity:
- composeApp/.../media/ImageDownsampler.kt: BitmapFactory inSampleSize
+ proportional scale + JPEG compress. Same profiles as iOS.
- composeApp/.../network/UploadApi.kt: Ktor-based presign + direct-to-B2
POST. Preserves form-field order so the S3 policy signature validates.
- APILayer.uploadImage(category, contentType, bytes, fileName) → upload_id.
UI integration to follow.
Shared (Kotlin):
- models/TaskCompletion.kt: added uploadIds: List<Int>? to
TaskCompletionCreateRequest and a new PresignUploadRequest /
PresignUploadResponse pair matching the Go API DTOs.
- Existing call sites (WidgetActionProcessor, PushNotificationManager)
explicitly pass uploadIds: nil for backwards compatibility — Swift's
bridge to Kotlin doesn't honor Kotlin defaults for required-positional
parameters.
The legacy multipart path remains functional alongside the new one for
soak-test purposes; per-platform feature flags can flip between them at
any time. After zero multipart traffic in production for 7 consecutive
days, the legacy paths can be dropped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// Memory-efficient image resizer for upload preprocessing.
|
||||
///
|
||||
/// Why not `UIImage.jpegData(compressionQuality:)` directly? UIImage decodes
|
||||
/// the entire source bitmap into RAM before re-encoding — a 12 MP iPhone
|
||||
/// photo decompresses to ~50 MB regardless of how big the JPEG is. With
|
||||
/// multiple selected images this can blow up memory on older devices.
|
||||
///
|
||||
/// `CGImageSourceCreateThumbnailAtIndex` reads the source incrementally and
|
||||
/// only allocates the *resized* bitmap, paying memory proportional to the
|
||||
/// output size (a 2048×1536 thumbnail is ~12 MB, but the source is never
|
||||
/// fully decoded).
|
||||
///
|
||||
/// Reference: https://nshipster.com/image-resizing/ — section "Image I/O".
|
||||
enum ImageDownsampler {
|
||||
|
||||
/// Settings tuned per upload category. Edit here, not at call sites.
|
||||
struct Profile {
|
||||
/// Largest dimension (in points-after-scale, i.e. pixels) of the
|
||||
/// downsampled image. The shorter edge is set proportionally.
|
||||
let maxPixelEdge: CGFloat
|
||||
|
||||
/// JPEG quality, 0...1. 0.85 is the WhatsApp / Slack default —
|
||||
/// visually indistinguishable from quality 1.0 at typical viewing
|
||||
/// sizes; cuts file size by ~3x.
|
||||
let jpegQuality: CGFloat
|
||||
|
||||
static let completion = Profile(maxPixelEdge: 2048, jpegQuality: 0.85)
|
||||
static let documentImage = Profile(maxPixelEdge: 2560, jpegQuality: 0.90)
|
||||
}
|
||||
|
||||
/// Downsample raw image bytes (e.g. from a `PHPickerResult`'s
|
||||
/// `loadDataRepresentation`) into a JPEG `Data` ready for upload.
|
||||
///
|
||||
/// - Returns: encoded JPEG bytes, or nil if decoding failed.
|
||||
static func downsample(data: Data, profile: Profile) -> Data? {
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageSourceShouldCache: false, // don't keep the full image around
|
||||
kCGImageSourceTypeIdentifierHint: UTType.jpeg.identifier as CFString, // best-effort hint
|
||||
]
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, options as CFDictionary) else {
|
||||
return nil
|
||||
}
|
||||
return downsample(source: source, profile: profile)
|
||||
}
|
||||
|
||||
/// Downsample from a file URL (e.g. PhotosPicker's
|
||||
/// `loadFileRepresentation`). Avoids materializing the full image in
|
||||
/// memory before resize.
|
||||
static func downsample(url: URL, profile: Profile) -> Data? {
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageSourceShouldCache: false,
|
||||
]
|
||||
guard let source = CGImageSourceCreateWithURL(url as CFURL, options as CFDictionary) else {
|
||||
return nil
|
||||
}
|
||||
return downsample(source: source, profile: profile)
|
||||
}
|
||||
|
||||
/// Convenience for callers that already have a `UIImage` (e.g. from
|
||||
/// `UIImagePickerController`). We round-trip through PNG to get raw
|
||||
/// data, then use the data path. Slightly less efficient than starting
|
||||
/// from URL/Data, but still avoids the JPEG re-encode penalty for the
|
||||
/// resize step itself.
|
||||
static func downsample(uiImage: UIImage, profile: Profile) -> Data? {
|
||||
// Use PNG for the intermediate to avoid double-JPEG quality loss.
|
||||
// Even though PNG is larger, this stays in memory only briefly.
|
||||
guard let intermediate = uiImage.pngData() else { return nil }
|
||||
return downsample(data: intermediate, profile: profile)
|
||||
}
|
||||
|
||||
// MARK: - Internal
|
||||
|
||||
private static func downsample(source: CGImageSource, profile: Profile) -> Data? {
|
||||
// Compute the max pixel size in screen-resolution-aware units. We
|
||||
// use a fixed pixel cap because uploads are about bytes, not display.
|
||||
let scale: CGFloat = 1.0
|
||||
let maxDimensionInPixels = profile.maxPixelEdge * scale
|
||||
|
||||
let downsampleOptions: [CFString: Any] = [
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceShouldCacheImmediately: true, // decode on the calling thread
|
||||
kCGImageSourceCreateThumbnailWithTransform: true, // honor EXIF orientation
|
||||
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels,
|
||||
]
|
||||
|
||||
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(
|
||||
source, 0, downsampleOptions as CFDictionary
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let uiImage = UIImage(cgImage: cgImage)
|
||||
return uiImage.jpegData(compressionQuality: profile.jpegQuality)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import Foundation
|
||||
import ComposeApp
|
||||
|
||||
/// Three-step direct-to-B2 image upload.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. POST /api/uploads/presign → server returns a B2 POST policy + form
|
||||
/// fields scoped to a single object key with a content-length-range
|
||||
/// condition that B2 enforces at the protocol level.
|
||||
/// 2. Multipart POST the bytes directly to B2, no API server in the data
|
||||
/// path. B2 rejects the upload if the bytes don't match the policy.
|
||||
/// 3. Caller passes the returned `uploadId` to /api/task-completions/ or
|
||||
/// /api/documents/ via `upload_ids[]`. The server HEADs the object,
|
||||
/// confirms the size, and creates the linked entity rows.
|
||||
///
|
||||
/// All errors map to `PresignedUploaderError` — the Swift call site can
|
||||
/// translate to user-facing copy without parsing nested HTTP details.
|
||||
enum PresignedUploaderError: Error, LocalizedError {
|
||||
case notAuthenticated
|
||||
case presignFailed(status: Int, body: String)
|
||||
case uploadFailed(status: Int, body: String)
|
||||
case sessionError(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notAuthenticated:
|
||||
return "You're not signed in."
|
||||
case .presignFailed(let status, _):
|
||||
switch status {
|
||||
case 413: return "That photo is too large after resizing. Try a different one."
|
||||
case 422: return "That image format isn't supported."
|
||||
case 429: return "You're uploading too many photos. Try again in a few minutes."
|
||||
default: return "Couldn't start upload (server returned \(status))."
|
||||
}
|
||||
case .uploadFailed(let status, _):
|
||||
return "Upload failed (B2 returned \(status))."
|
||||
case .sessionError(let err):
|
||||
return err.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Category passed to the presign endpoint. Matches the Go server's
|
||||
/// `UploadCategory` constants in `internal/models/pending_upload.go`.
|
||||
enum UploadCategory: String {
|
||||
case completion = "completion"
|
||||
case documentImage = "document_image"
|
||||
case documentFile = "document_file"
|
||||
}
|
||||
|
||||
/// Presigned-URL upload helper. Stateless — instantiate freely.
|
||||
///
|
||||
/// Concurrency: each `upload(...)` call runs to completion sequentially.
|
||||
/// For multiple images the caller can run several uploads in parallel via
|
||||
/// `withTaskGroup`; the server's per-user concurrency cap (10 in-flight
|
||||
/// presigns) is enforced server-side.
|
||||
final class PresignedUploader {
|
||||
|
||||
/// API base URL — read from KMP's ApiConfig so iOS and Android stay
|
||||
/// in sync (LOCAL vs DEV vs PROD without divergent constants).
|
||||
private let apiBaseURL: String
|
||||
|
||||
/// Bearer token. Read once at init; if the user re-auths mid-session,
|
||||
/// the caller should construct a fresh PresignedUploader.
|
||||
private let authToken: String
|
||||
|
||||
private let session: URLSession
|
||||
|
||||
init?(session: URLSession = .shared) {
|
||||
// ApiConfig.shared.getBaseUrl() resolves Environment (LOCAL/DEV/PROD).
|
||||
// DataManager.shared.authToken is a StateFlow<String?> — read the
|
||||
// current value via .value (SKIE-exposed property).
|
||||
let baseUrl = ApiConfig.shared.getBaseUrl()
|
||||
guard let token = DataManager.shared.authToken.value as String? else {
|
||||
return nil
|
||||
}
|
||||
self.apiBaseURL = baseUrl
|
||||
self.authToken = token
|
||||
self.session = session
|
||||
}
|
||||
|
||||
/// Upload `data` to B2 in the named category. Returns the
|
||||
/// pending_uploads.id the caller passes via `upload_ids[]` to attach
|
||||
/// the object to a real entity.
|
||||
func upload(
|
||||
data: Data,
|
||||
category: UploadCategory,
|
||||
contentType: String = "image/jpeg",
|
||||
fileName: String = "image.jpg"
|
||||
) async throws -> Int32 {
|
||||
// Step 1: presign
|
||||
let presigned = try await requestPresign(
|
||||
category: category,
|
||||
contentType: contentType,
|
||||
contentLength: Int64(data.count)
|
||||
)
|
||||
|
||||
// Step 2: direct POST to B2
|
||||
try await postToStorage(
|
||||
uploadURL: presigned.uploadUrl,
|
||||
fields: presigned.fields,
|
||||
data: data,
|
||||
contentType: contentType,
|
||||
fileName: fileName
|
||||
)
|
||||
|
||||
return Int32(presigned.id)
|
||||
}
|
||||
|
||||
/// Upload several images in parallel, returning their upload_ids in
|
||||
/// input order. Stops at the first failure and surfaces it.
|
||||
func uploadAll(
|
||||
items: [(Data, String)],
|
||||
category: UploadCategory,
|
||||
contentType: String = "image/jpeg"
|
||||
) async throws -> [Int32] {
|
||||
try await withThrowingTaskGroup(of: (Int, Int32).self) { group in
|
||||
for (idx, item) in items.enumerated() {
|
||||
let (data, name) = item
|
||||
group.addTask { [self] in
|
||||
let id = try await upload(
|
||||
data: data,
|
||||
category: category,
|
||||
contentType: contentType,
|
||||
fileName: name
|
||||
)
|
||||
return (idx, id)
|
||||
}
|
||||
}
|
||||
var pairs: [(Int, Int32)] = []
|
||||
for try await pair in group {
|
||||
pairs.append(pair)
|
||||
}
|
||||
return pairs.sorted { $0.0 < $1.0 }.map { $0.1 }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Step 1: presign
|
||||
|
||||
private struct PresignBody: Encodable {
|
||||
let category: String
|
||||
let content_type: String
|
||||
let content_length: Int64
|
||||
}
|
||||
|
||||
private struct PresignResponse: Decodable {
|
||||
let id: Int
|
||||
let upload_url: String
|
||||
let fields: [String: String]
|
||||
let key: String
|
||||
let expires_at: String
|
||||
|
||||
// Map snake_case to nicer Swift names at the call site.
|
||||
var uploadUrl: String { upload_url }
|
||||
}
|
||||
|
||||
private func requestPresign(
|
||||
category: UploadCategory,
|
||||
contentType: String,
|
||||
contentLength: Int64
|
||||
) async throws -> PresignResponse {
|
||||
guard var url = URL(string: apiBaseURL) else {
|
||||
throw PresignedUploaderError.presignFailed(status: 0, body: "invalid base url")
|
||||
}
|
||||
url.appendPathComponent("uploads/presign/")
|
||||
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("Token \(authToken)", forHTTPHeaderField: "Authorization")
|
||||
req.httpBody = try JSONEncoder().encode(PresignBody(
|
||||
category: category.rawValue,
|
||||
content_type: contentType,
|
||||
content_length: contentLength
|
||||
))
|
||||
|
||||
let (body, response): (Data, URLResponse)
|
||||
do {
|
||||
(body, response) = try await session.data(for: req)
|
||||
} catch {
|
||||
throw PresignedUploaderError.sessionError(error)
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw PresignedUploaderError.presignFailed(status: 0, body: "no response")
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw PresignedUploaderError.presignFailed(
|
||||
status: http.statusCode,
|
||||
body: String(data: body, encoding: .utf8) ?? ""
|
||||
)
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(PresignResponse.self, from: body)
|
||||
} catch {
|
||||
throw PresignedUploaderError.presignFailed(status: http.statusCode, body: "decode failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Step 2: POST to B2
|
||||
|
||||
private func postToStorage(
|
||||
uploadURL: String,
|
||||
fields: [String: String],
|
||||
data: Data,
|
||||
contentType: String,
|
||||
fileName: String
|
||||
) async throws {
|
||||
guard let url = URL(string: uploadURL) else {
|
||||
throw PresignedUploaderError.uploadFailed(status: 0, body: "invalid upload url")
|
||||
}
|
||||
|
||||
// Build a multipart/form-data body with all policy fields followed
|
||||
// by a single "file" part (S3 POST policy mandates the file part
|
||||
// come last).
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
var body = Data()
|
||||
let crlf = "\r\n"
|
||||
let appendString: (String) -> Void = { s in
|
||||
body.append(s.data(using: .utf8) ?? Data())
|
||||
}
|
||||
|
||||
// Stable order: ensure "key" and "Content-Type" appear before the
|
||||
// file part so the policy signature validates. Unspecified order
|
||||
// for the rest — S3 accepts any.
|
||||
let orderedKeys = ["key", "Content-Type", "policy", "x-amz-algorithm",
|
||||
"x-amz-credential", "x-amz-date", "x-amz-signature",
|
||||
"x-amz-meta-uid"]
|
||||
var emitted = Set<String>()
|
||||
for k in orderedKeys {
|
||||
if let v = fields[k] {
|
||||
appendString("--\(boundary)\(crlf)")
|
||||
appendString("Content-Disposition: form-data; name=\"\(k)\"\(crlf)\(crlf)")
|
||||
appendString(v)
|
||||
appendString(crlf)
|
||||
emitted.insert(k)
|
||||
}
|
||||
}
|
||||
for (k, v) in fields where !emitted.contains(k) {
|
||||
appendString("--\(boundary)\(crlf)")
|
||||
appendString("Content-Disposition: form-data; name=\"\(k)\"\(crlf)\(crlf)")
|
||||
appendString(v)
|
||||
appendString(crlf)
|
||||
}
|
||||
|
||||
// file part — must be last
|
||||
appendString("--\(boundary)\(crlf)")
|
||||
appendString("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\(crlf)")
|
||||
appendString("Content-Type: \(contentType)\(crlf)\(crlf)")
|
||||
body.append(data)
|
||||
appendString(crlf)
|
||||
appendString("--\(boundary)--\(crlf)")
|
||||
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = body
|
||||
|
||||
let (respBody, response): (Data, URLResponse)
|
||||
do {
|
||||
(respBody, response) = try await session.data(for: req)
|
||||
} catch {
|
||||
throw PresignedUploaderError.sessionError(error)
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw PresignedUploaderError.uploadFailed(status: 0, body: "no response")
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw PresignedUploaderError.uploadFailed(
|
||||
status: http.statusCode,
|
||||
body: String(data: respBody, encoding: .utf8) ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,8 @@ final class WidgetActionProcessor {
|
||||
notes: "Completed from widget",
|
||||
actualCost: nil,
|
||||
rating: nil,
|
||||
imageUrls: nil
|
||||
imageUrls: nil,
|
||||
uploadIds: nil
|
||||
)
|
||||
|
||||
let result = try await APILayer.shared.createTaskCompletion(request: request)
|
||||
|
||||
@@ -388,7 +388,8 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
notes: nil,
|
||||
actualCost: nil,
|
||||
rating: nil,
|
||||
imageUrls: nil
|
||||
imageUrls: nil,
|
||||
uploadIds: nil
|
||||
)
|
||||
let result = try await APILayer.shared.createTaskCompletion(request: request)
|
||||
|
||||
|
||||
@@ -337,51 +337,121 @@ struct CompleteTaskView: View {
|
||||
|
||||
isSubmitting = true
|
||||
|
||||
// Create request with simplified Go API format
|
||||
// Note: completedAt defaults to now on server if not provided
|
||||
let request = TaskCompletionCreateRequest(
|
||||
taskId: task.id,
|
||||
completedAt: nil,
|
||||
notes: notes.isEmpty ? nil : notes,
|
||||
actualCost: actualCost.isEmpty ? nil : KotlinDouble(double: Double(actualCost) ?? 0.0),
|
||||
rating: KotlinInt(int: Int32(rating)),
|
||||
imageUrls: nil // Images uploaded separately and URLs added by handler
|
||||
)
|
||||
|
||||
// Use TaskCompletionViewModel to create completion
|
||||
// New direct-to-B2 upload path: downsample on-device, presign, POST
|
||||
// straight to B2, pass the resulting upload_ids to the completion
|
||||
// create call. Bytes never traverse our API server. See
|
||||
// /api/uploads/presign in honeyDueAPI-go.
|
||||
if !selectedImages.isEmpty {
|
||||
// Convert images to ImageData for Kotlin
|
||||
let imageDataList = selectedImages.compactMap { uiImage -> ComposeApp.ImageData? in
|
||||
guard let jpegData = uiImage.jpegData(compressionQuality: 0.8) else { return nil }
|
||||
let byteArray = KotlinByteArray(data: jpegData)
|
||||
return ComposeApp.ImageData(bytes: byteArray, fileName: "completion_image.jpg")
|
||||
}
|
||||
completionViewModel.createTaskCompletionWithImages(request: request, images: imageDataList)
|
||||
uploadAndCreate()
|
||||
} else {
|
||||
// No images — go straight to the completion create.
|
||||
let request = TaskCompletionCreateRequest(
|
||||
taskId: task.id,
|
||||
completedAt: nil,
|
||||
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)
|
||||
observeCompletionState()
|
||||
}
|
||||
}
|
||||
|
||||
// Observe the result — store the Task so it can be cancelled on dismiss
|
||||
/// Async pipeline: downsample → presign+upload to B2 → create completion
|
||||
/// with the returned upload_ids. Errors at any stage become a single
|
||||
/// alert; partial uploads (1 of 3 succeeded) currently fail the whole
|
||||
/// flow — server-side cleanup reaps the orphans within the hour.
|
||||
private func uploadAndCreate() {
|
||||
observationTask?.cancel()
|
||||
observationTask = Task {
|
||||
for await state in completionViewModel.createCompletionState {
|
||||
if Task.isCancelled { break }
|
||||
// Step 1: downsample each image. Runs on the calling task; the
|
||||
// ImageDownsampler is memory-bounded so this is safe for the
|
||||
// expected batch sizes (≤5 images).
|
||||
let payloads: [(Data, String)] = selectedImages.compactMap { uiImage -> (Data, String)? in
|
||||
guard let data = ImageDownsampler.downsample(uiImage: uiImage, profile: .completion) else {
|
||||
return nil
|
||||
}
|
||||
return (data, "completion_\(UUID().uuidString).jpg")
|
||||
}
|
||||
guard payloads.count == selectedImages.count else {
|
||||
await MainActor.run {
|
||||
if let success = state as? ApiResultSuccess<TaskCompletionResponse> {
|
||||
self.isSubmitting = false
|
||||
self.onComplete(success.data?.updatedTask) // Pass back updated task
|
||||
self.dismiss()
|
||||
} else if let error = ApiResultBridge.error(from: state) {
|
||||
self.errorMessage = error.message
|
||||
self.showError = true
|
||||
self.isSubmitting = false
|
||||
}
|
||||
errorMessage = "One or more photos couldn't be processed."
|
||||
showError = true
|
||||
isSubmitting = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Break out of loop on terminal states
|
||||
if state is ApiResultSuccess<TaskCompletionResponse> || ApiResultBridge.isError(state) {
|
||||
break
|
||||
// Step 2: presign + upload each to B2. PresignedUploader runs
|
||||
// them in parallel under a server-enforced concurrency cap of 10.
|
||||
guard let uploader = PresignedUploader() else {
|
||||
await MainActor.run {
|
||||
errorMessage = "Not authenticated"
|
||||
showError = true
|
||||
isSubmitting = false
|
||||
}
|
||||
return
|
||||
}
|
||||
let uploadIds: [Int32]
|
||||
do {
|
||||
uploadIds = try await uploader.uploadAll(items: payloads, category: .completion)
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
errorMessage = (error as? PresignedUploaderError)?.errorDescription
|
||||
?? error.localizedDescription
|
||||
showError = true
|
||||
isSubmitting = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Step 3: create completion via the existing endpoint, passing
|
||||
// upload_ids so the server claims the pending_uploads rows and
|
||||
// turns them into TaskCompletionImage rows.
|
||||
let request = TaskCompletionCreateRequest(
|
||||
taskId: task.id,
|
||||
completedAt: nil,
|
||||
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 {
|
||||
completionViewModel.createTaskCompletion(request: request)
|
||||
}
|
||||
await observeCompletionStateAsync()
|
||||
}
|
||||
}
|
||||
|
||||
/// Observe the createCompletionState StateFlow until a terminal value
|
||||
/// arrives, then dismiss or surface an error. Called from the
|
||||
/// no-images path.
|
||||
private func observeCompletionState() {
|
||||
observationTask?.cancel()
|
||||
observationTask = Task {
|
||||
await observeCompletionStateAsync()
|
||||
}
|
||||
}
|
||||
|
||||
private func observeCompletionStateAsync() async {
|
||||
for await state in completionViewModel.createCompletionState {
|
||||
if Task.isCancelled { break }
|
||||
await MainActor.run {
|
||||
if let success = state as? ApiResultSuccess<TaskCompletionResponse> {
|
||||
self.isSubmitting = false
|
||||
self.onComplete(success.data?.updatedTask)
|
||||
self.dismiss()
|
||||
} else if let error = ApiResultBridge.error(from: state) {
|
||||
self.errorMessage = error.message
|
||||
self.showError = true
|
||||
self.isSubmitting = false
|
||||
}
|
||||
}
|
||||
if state is ApiResultSuccess<TaskCompletionResponse> || ApiResultBridge.isError(state) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user