325 lines
13 KiB
Swift
325 lines
13 KiB
Swift
import SwiftUI
|
|
import PhotosUI
|
|
import ComposeApp
|
|
|
|
struct CompleteTaskView: View {
|
|
let task: TaskDetail
|
|
@Binding var isPresented: Bool
|
|
let onComplete: () -> Void
|
|
|
|
@StateObject private var taskViewModel = TaskViewModel()
|
|
@State private var completedByName: String = ""
|
|
@State private var actualCost: String = ""
|
|
@State private var notes: String = ""
|
|
@State private var rating: Int = 3
|
|
@State private var selectedItems: [PhotosPickerItem] = []
|
|
@State private var selectedImages: [UIImage] = []
|
|
@State private var isSubmitting: Bool = false
|
|
@State private var showError: Bool = false
|
|
@State private var errorMessage: String = ""
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
// Task Info Section
|
|
Section {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(task.title)
|
|
.font(.headline)
|
|
|
|
HStack {
|
|
Label(task.category.name.capitalized, systemImage: "folder")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Spacer()
|
|
|
|
if let status = task.status {
|
|
Text(status.displayName)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(.quaternary)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Task Details")
|
|
}
|
|
|
|
// Completion Details Section
|
|
Section {
|
|
LabeledContent {
|
|
TextField("Your name", text: $completedByName)
|
|
.multilineTextAlignment(.trailing)
|
|
} label: {
|
|
Label("Completed By", systemImage: "person")
|
|
}
|
|
|
|
LabeledContent {
|
|
TextField("0.00", text: $actualCost)
|
|
.keyboardType(.decimalPad)
|
|
.multilineTextAlignment(.trailing)
|
|
.overlay(alignment: .leading) {
|
|
Text("$")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.leading, 12)
|
|
} label: {
|
|
Label("Actual Cost", systemImage: "dollarsign.circle")
|
|
}
|
|
} header: {
|
|
Text("Optional Information")
|
|
} footer: {
|
|
Text("Add any additional details about completing this task.")
|
|
}
|
|
|
|
// Notes Section
|
|
Section {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Label("Notes", systemImage: "note.text")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
|
|
TextEditor(text: $notes)
|
|
.frame(minHeight: 100)
|
|
.scrollContentBackground(.hidden)
|
|
}
|
|
} footer: {
|
|
Text("Optional notes about the work completed.")
|
|
}
|
|
|
|
// Rating Section
|
|
Section {
|
|
VStack(spacing: 12) {
|
|
HStack {
|
|
Label("Quality Rating", systemImage: "star")
|
|
.font(.subheadline)
|
|
|
|
Spacer()
|
|
|
|
Text("\(rating) / 5")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
HStack(spacing: 16) {
|
|
ForEach(1...5, id: \.self) { star in
|
|
Image(systemName: star <= rating ? "star.fill" : "star")
|
|
.font(.title2)
|
|
.foregroundStyle(star <= rating ? .yellow : .gray)
|
|
.symbolRenderingMode(.hierarchical)
|
|
.onTapGesture {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
rating = star
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
} footer: {
|
|
Text("Rate the quality of work from 1 to 5 stars.")
|
|
}
|
|
|
|
// Images Section
|
|
Section {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
PhotosPicker(
|
|
selection: $selectedItems,
|
|
maxSelectionCount: 5,
|
|
matching: .images,
|
|
photoLibrary: .shared()
|
|
) {
|
|
Label("Add Photos", systemImage: "photo.on.rectangle.angled")
|
|
.frame(maxWidth: .infinity)
|
|
.foregroundStyle(.blue)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.onChange(of: selectedItems) { newItems in
|
|
Task {
|
|
selectedImages = []
|
|
for item in newItems {
|
|
if let data = try? await item.loadTransferable(type: Data.self),
|
|
let image = UIImage(data: data) {
|
|
selectedImages.append(image)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Display selected images
|
|
if !selectedImages.isEmpty {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 12) {
|
|
ForEach(selectedImages.indices, id: \.self) { index in
|
|
ImageThumbnailView(
|
|
image: selectedImages[index],
|
|
onRemove: {
|
|
withAnimation {
|
|
selectedImages.remove(at: index)
|
|
selectedItems.remove(at: index)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Photos (\(selectedImages.count)/5)")
|
|
} footer: {
|
|
Text("Add up to 5 photos documenting the completed work.")
|
|
}
|
|
|
|
// Complete Button Section
|
|
Section {
|
|
Button(action: handleComplete) {
|
|
HStack {
|
|
if isSubmitting {
|
|
ProgressView()
|
|
.tint(.white)
|
|
} else {
|
|
Label("Complete Task", systemImage: "checkmark.circle.fill")
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.fontWeight(.semibold)
|
|
}
|
|
.listRowBackground(isSubmitting ? Color.gray : Color.green)
|
|
.foregroundStyle(.white)
|
|
.disabled(isSubmitting)
|
|
}
|
|
}
|
|
.navigationTitle("Complete Task")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") {
|
|
isPresented = false
|
|
}
|
|
}
|
|
}
|
|
.alert("Error", isPresented: $showError) {
|
|
Button("OK", role: .cancel) {}
|
|
} message: {
|
|
Text(errorMessage)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleComplete() {
|
|
isSubmitting = true
|
|
|
|
guard let token = TokenStorage.shared.getToken() else {
|
|
errorMessage = "Not authenticated"
|
|
showError = true
|
|
isSubmitting = false
|
|
return
|
|
}
|
|
|
|
// Get current date in ISO format
|
|
let dateFormatter = ISO8601DateFormatter()
|
|
let currentDate = dateFormatter.string(from: Date())
|
|
|
|
// Create request
|
|
let request = TaskCompletionCreateRequest(
|
|
task: task.id,
|
|
completedByUser: nil,
|
|
completedByName: completedByName.isEmpty ? nil : completedByName,
|
|
completionDate: currentDate,
|
|
actualCost: actualCost.isEmpty ? nil : actualCost,
|
|
notes: notes.isEmpty ? nil : notes,
|
|
rating: KotlinInt(int: Int32(rating))
|
|
)
|
|
|
|
let completionApi = TaskCompletionApi(client: ApiClient_iosKt.createHttpClient())
|
|
|
|
// If there are images, upload with images
|
|
if !selectedImages.isEmpty {
|
|
let imageDataArray = selectedImages.compactMap { $0.jpegData(compressionQuality: 0.8) }
|
|
let imageByteArrays = imageDataArray.map { KotlinByteArray(data: $0) }
|
|
let fileNames = (0..<imageDataArray.count).map { "image_\($0).jpg" }
|
|
|
|
completionApi.createCompletionWithImages(
|
|
token: token,
|
|
request: request,
|
|
images: imageByteArrays,
|
|
imageFileNames: fileNames
|
|
) { result, error in
|
|
handleCompletionResult(result: result, error: error)
|
|
}
|
|
} else {
|
|
// Upload without images
|
|
completionApi.createCompletion(token: token, request: request) { result, error in
|
|
handleCompletionResult(result: result, error: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleCompletionResult(result: ApiResult<TaskCompletion>?, error: Error?) {
|
|
DispatchQueue.main.async {
|
|
if result is ApiResultSuccess<TaskCompletion> {
|
|
self.isSubmitting = false
|
|
self.isPresented = false
|
|
self.onComplete()
|
|
} else if let errorResult = result as? ApiResultError {
|
|
self.errorMessage = errorResult.message
|
|
self.showError = true
|
|
self.isSubmitting = false
|
|
} else if let error = error {
|
|
self.errorMessage = error.localizedDescription
|
|
self.showError = true
|
|
self.isSubmitting = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper extension to convert Data to KotlinByteArray
|
|
extension KotlinByteArray {
|
|
convenience init(data: Data) {
|
|
let array = [UInt8](data)
|
|
self.init(size: Int32(array.count))
|
|
for (index, byte) in array.enumerated() {
|
|
self.set(index: Int32(index), value: Int8(bitPattern: byte))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Image Thumbnail View Component
|
|
struct ImageThumbnailView: View {
|
|
let image: UIImage
|
|
let onRemove: () -> Void
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .topTrailing) {
|
|
Image(uiImage: image)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(width: 100, height: 100)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.strokeBorder(.quaternary, lineWidth: 1)
|
|
}
|
|
|
|
Button(action: onRemove) {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.title3)
|
|
.foregroundStyle(.white)
|
|
.background {
|
|
Circle()
|
|
.fill(.black.opacity(0.6))
|
|
.padding(4)
|
|
}
|
|
}
|
|
.offset(x: 8, y: -8)
|
|
}
|
|
}
|
|
}
|