import SwiftUI import UniformTypeIdentifiers import PhotosUI import PDFKit struct ChatAttachment: Codable, Identifiable { var id: String; var filename: String; var mime: String; var size: Int; var extractionStatus: String; var createdAt: Double } struct ChatArtifact: Codable, Identifiable { var id: String; var title: String; var kind: String; var revision: Int; var content: String?; var createdAt: Double; var updatedAt: Double } struct ScheduledChatTask: Codable, Identifiable { var id: String; var name: String?; var schedule: String?; var prompt: String?; var enabled: Bool?; var `repeat`: Int? } struct ChatMemory: Codable, Identifiable { var section: String; var content: String; var revision: String; var id: String { section } } struct FeatureCapabilities: Decodable { let features: [String: BoolValue] } /// Capabilities may carry limits/strings alongside bool flags; only explicit true enables UI. struct BoolValue: Decodable { let enabled: Bool; init(from decoder: Decoder) throws { enabled = (try? decoder.singleValueContainer().decode(Bool.self)) == true } } @MainActor final class ChatFeatureStore: ObservableObject { @Published var enabled: Set = [] @Published var attachments: [ChatAttachment] = [] @Published var artifacts: [ChatArtifact] = [] @Published var tasks: [ScheduledChatTask] = [] @Published var memory: [ChatMemory] = [] @Published var error: String? @Published var busy = false @Published var previewText: String? @Published var previewImage: UIImage? @Published var previewPDF: Data? @Published var previewAttachment: ChatAttachment? @Published var previewLoading = false @Published var previewError: String? var onUnauthorized: (() async -> Void)? private let api: ChatTransport private var epoch = 0 private var closed = false private var capabilityGeneration = 0 private var loadGeneration = 0 init(api: ChatTransport) { self.api = api } private func call(_ path: String, method: String = "GET", body: [String: Any]? = nil, query: [URLQueryItem] = []) async throws -> T { guard !closed else { throw CancellationError() } let current = epoch let data = try await api.request(path, method: method, body: body, query: query) guard !closed, current == epoch else { throw CancellationError() } try Task.checkCancellation() return try ChatCoding.decoder.decode(T.self, from: data) } @discardableResult func refreshCapabilities() async -> Bool { guard !closed else { return false } capabilityGeneration += 1 let generation = capabilityGeneration enabled = [] // Never retain image qualification while a refresh is pending or failed. do { let caps: FeatureCapabilities = try await call("api/capabilities") guard generation == capabilityGeneration else { return false } enabled = Set(caps.features.filter { $0.value.enabled }.map(\.key)) return true } catch { guard generation == capabilityGeneration else { return false } report(error); return false } } func load() async { loadGeneration += 1 let generation = loadGeneration guard await refreshCapabilities() else { return } let capabilities = capabilityGeneration do { if enabled.contains("attachments") { struct Page: Decodable { let attachments: [ChatAttachment] } let page: Page = try await call("api/attachments") guard generation == loadGeneration, capabilities == capabilityGeneration else { return } attachments = page.attachments } else { attachments = [] } if enabled.contains("artifacts") { struct Page: Decodable { let artifacts: [ChatArtifact] } let page: Page = try await call("api/artifacts") guard generation == loadGeneration, capabilities == capabilityGeneration else { return } artifacts = page.artifacts } else { artifacts = [] } if enabled.contains("scheduled_tasks") { struct Page: Decodable { let tasks: [ScheduledChatTask] } let page: Page = try await call("api/tasks") guard generation == loadGeneration, capabilities == capabilityGeneration else { return } tasks = page.tasks } else { tasks = [] } if enabled.contains("memory_controls") { struct Page: Decodable { let sections: [ChatMemory] } let page: Page = try await call("api/memory") guard generation == loadGeneration, capabilities == capabilityGeneration else { return } memory = page.sections } else { memory = [] } error = nil } catch { if generation == loadGeneration { report(error) } } } static func blockedReason(_ item: ChatAttachment, imageInput: Bool) -> String? { if ["failed", "encrypted", "unavailable"].contains(item.extractionStatus) { return "This file cannot be used: \(item.extractionStatus). Preview it or upload a readable copy." } guard ["ready", "truncated", "image"].contains(item.extractionStatus) else { return "This file is not ready to use. Refresh and try again." } if item.mime.hasPrefix("image/") && !imageInput { return "Image input is not available for the current model. Choose a text file or PDF." } return nil } func validatedAttachment(_ item: ChatAttachment) async -> ChatAttachment? { guard await refreshCapabilities(), enabled.contains("attachments") else { if !closed { error = "Private files are unavailable. Refresh and try again." }; return nil } let generation = capabilityGeneration do { struct Envelope: Decodable { let attachment: ChatAttachment } let result: Envelope = try await call("api/attachments/\(item.id)") guard generation == capabilityGeneration else { return nil } if let reason = Self.blockedReason(result.attachment, imageInput: enabled.contains("image_input")) { error = reason; return nil } if let i = attachments.firstIndex(where: { $0.id == item.id }) { attachments[i] = result.attachment } error = nil; return result.attachment } catch { guard !closed, generation == capabilityGeneration else { return nil } if case ChatFailure.status(404) = error { self.error = "This file is no longer available. Refresh your files." } else { report(error) } return nil } } private func report(_ value: Error) { guard !(value is CancellationError), !closed else { return } if case ChatFailure.status(let code) = value, code == 401 || code == 403 { Task { await onUnauthorized?() }; return } error = value.localizedDescription } func upload(_ data: Data, filename: String, mime: String) async -> ChatAttachment? { guard !busy, !closed else { return nil } guard await refreshCapabilities(), enabled.contains("attachments"), !busy else { return nil } if mime.hasPrefix("image/"), !enabled.contains("image_input") { error = "Image input is unavailable for the current model. Choose a text file or PDF."; return nil } guard data.count <= 8 * 1024 * 1024 else { error = "Choose a file smaller than 8 MiB."; return nil } busy = true; defer { busy = false } do { let current = epoch struct Envelope: Decodable { let attachment: ChatAttachment } let bytes = try await api.upload(data: data, filename: filename, mime: mime) guard current == epoch, !closed else { return nil } let value = try ChatCoding.decoder.decode(Envelope.self, from: bytes).attachment attachments.insert(value, at: 0); error = nil; return value } catch { report(error); return nil } } func preview(_ attachment: ChatAttachment) async { guard !closed else { return } previewAttachment = attachment previewLoading = true previewError = nil previewPDF = nil; previewText = nil; previewImage = nil defer { if previewAttachment?.id == attachment.id { previewLoading = false } } do { let current = epoch let data = try await api.request("api/attachments/\(attachment.id)/content", method: "GET", body: nil, query: []) guard current == epoch, !closed, previewAttachment?.id == attachment.id else { return } if attachment.mime.hasPrefix("image/") { guard let image = UIImage(data: data) else { throw ChatFailure.invalidResponse } previewImage = image } else if attachment.mime == "application/pdf" { guard PDFDocument(data: data) != nil else { throw ChatFailure.invalidResponse } previewPDF = data } else if let text = String(data: data, encoding: .utf8) { previewText = text } else { throw ChatFailure.invalidResponse } } catch is CancellationError { return } catch { guard previewAttachment?.id == attachment.id else { return } previewError = "Couldn’t open \(attachment.filename). Try again." } } func clearPreview() { previewAttachment = nil; previewLoading = false; previewError = nil previewText = nil; previewImage = nil; previewPDF = nil } func deleteAttachment(_ item: ChatAttachment) async { await remove("api/attachments/\(item.id)"); if error == nil { attachments.removeAll { $0.id == item.id } } } private func remove(_ path: String) async { do { struct Result: Decodable { let ok: Bool }; let _: Result = try await call(path, method: "DELETE"); error = nil } catch { report(error) } } func saveArtifact(_ item: ChatArtifact?, title: String, kind: String, content: String) async { do { var body: [String: Any] = ["title": title, "kind": kind, "content": content] if let item { body["revision"] = item.revision } struct Result: Decodable { let artifact: ChatArtifact } let result: Result = try await call(item.map { "api/artifacts/\($0.id)" } ?? "api/artifacts", method: item == nil ? "POST" : "PATCH", body: body) if let i = artifacts.firstIndex(where: { $0.id == result.artifact.id }) { artifacts[i] = result.artifact } else { artifacts.insert(result.artifact, at: 0) }; error = nil } catch { report(error) } } func detail(_ item: ChatArtifact) async -> ChatArtifact? { do { struct Result: Decodable { let artifact: ChatArtifact }; let result: Result = try await call("api/artifacts/\(item.id)"); return result.artifact } catch { report(error); return nil } } func historical(_ item: ChatArtifact, revision: Int) async -> ChatArtifact? { do { struct Result: Decodable { let artifact: ChatArtifact }; let result: Result = try await call("api/artifacts/\(item.id)", query: [URLQueryItem(name: "revision", value: String(revision))]); return result.artifact } catch { report(error); return nil } } func deleteArtifact(_ item: ChatArtifact) async { await remove("api/artifacts/\(item.id)"); if error == nil { artifacts.removeAll { $0.id == item.id } } } func saveTask(_ item: ScheduledChatTask?, name: String, schedule: String, prompt: String, repeatCount: Int? = nil, paused: Bool = false) async { do { struct Result: Decodable { let task: ScheduledChatTask } var body: [String: Any] = ["name": name, "schedule": schedule, "prompt": prompt] if let repeatCount { guard repeatCount > 0 else { error = "Repeat count must be a positive whole number."; return }; body["repeat"] = repeatCount } if item == nil { body["paused"] = paused } let result: Result = try await call(item.map { "api/tasks/\($0.id)" } ?? "api/tasks", method: item == nil ? "POST" : "PATCH", body: body) if let i = tasks.firstIndex(where: { $0.id == result.task.id }) { tasks[i] = result.task } else { tasks.insert(result.task, at: 0) }; error = nil } catch { report(error) } } func taskAction(_ item: ScheduledChatTask, action: String) async { do { struct Result: Decodable { let task: ScheduledChatTask }; let result: Result = try await call("api/tasks/\(item.id)/\(action)", method: "POST"); if let i = tasks.firstIndex(where: { $0.id == item.id }) { tasks[i] = result.task }; error = nil } catch { report(error) } } func deleteTask(_ item: ScheduledChatTask) async { await remove("api/tasks/\(item.id)"); if error == nil { tasks.removeAll { $0.id == item.id } } } func latestMemory(_ item: ChatMemory) async -> ChatMemory? { do { struct Page: Decodable { let sections: [ChatMemory] } let page: Page = try await call("api/memory") guard let latest = page.sections.first(where: { $0.section == item.section }) else { throw ChatFailure.invalidResponse } memory = page.sections; error = nil; return latest } catch { report(error); return nil } } @discardableResult func saveMemory(_ item: ChatMemory, content: String) async -> Bool { do { let result: ChatMemory = try await call("api/memory/\(item.section)", method: "PUT", body: ["revision": item.revision, "content": content]) if let i = memory.firstIndex(where: { $0.id == item.id }) { memory[i] = result } error = nil; return true } catch { if case ChatFailure.status(409) = error { self.error = "Memory changed on the server. Compare the latest version, then explicitly accept its revision before saving your draft." } else { report(error) } return false } } func close() { closed = true; epoch += 1; capabilityGeneration += 1; loadGeneration += 1; enabled = []; attachments = []; artifacts = []; tasks = []; memory = []; clearPreview(); error = nil } } enum ChatLibraryDestination { case files, tasks, memory } struct ChatLibraryView: View { @ObservedObject var store: ChatFeatureStore var initialDestination: ChatLibraryDestination = .files var selectedAttachmentIds: Set = [] var onUseAttachment: ((ChatAttachment) async -> Bool)? var onRemoveAttachment: ((String) -> Void)? @Environment(\.dismiss) private var dismiss @State private var importing = false @State private var photo: PhotosPickerItem? @State private var showingPreview = false @State private var loadingCapabilities = true @State private var artifact: ChatArtifact? @State private var artifactEditor = false @State private var title = "" @State private var kind = "markdown" @State private var content = "" @State private var task: ScheduledChatTask? @State private var taskEditor = false @State private var name = "" @State private var schedule = "" @State private var prompt = "" @State private var repeatCount = "" @State private var createPaused = true @State private var usingAttachment = false @State private var feedback: String? @State private var latestMemory: ChatMemory? @State private var acceptedMemory: ChatMemory? @State private var memoryWorking = false @State private var memoryRequiresReview = false @State private var confirmingClear = false @State private var memory: ChatMemory? @State private var memoryContent = "" @State private var deleting: (() async -> Void)? var body: some View { NavigationStack { List { if let error = store.error { Section { Text(error).foregroundStyle(.red); Button("Retry") { Task { await store.load() } } } } if loadingCapabilities { ContentUnavailableView("Checking server capabilities", systemImage: "server.rack", description: Text("Only features your account supports are shown.")) } else if (initialDestination == .files && !store.enabled.contains("attachments") && !store.enabled.contains("artifacts")) || (initialDestination == .tasks && !store.enabled.contains("scheduled_tasks")) || (initialDestination == .memory && !store.enabled.contains("memory_controls")) { ContentUnavailableView("Not available on this server", systemImage: "lock", description: Text("These features must be enabled for your account by the Hope server.")) } if initialDestination == .files && store.enabled.contains("attachments") { Section("Private files") { Text("Upload private files, then use them in your next message. Nothing is sent automatically.").font(.footnote).foregroundStyle(.secondary) if let feedback { Text(feedback).font(.footnote) } if store.busy { ProgressView("Uploading private file…") } Button("Upload file", systemImage: "doc.badge.plus") { importing = true }.disabled(store.busy) if store.enabled.contains("image_input") { PhotosPicker(selection: $photo, matching: .images) { Label("Upload photo", systemImage: "photo") }.disabled(store.busy) } ForEach(store.attachments) { item in VStack(alignment: .leading) { HStack { Text(item.filename).font(.body) Spacer() Button("Preview") { showingPreview = true; Task { await store.preview(item) } }.frame(minHeight: 44) } Text("\(ByteCountFormatter.string(fromByteCount: Int64(item.size), countStyle: .file)) · \(item.extractionStatus)").font(.caption).foregroundStyle(.secondary) if let reason = ChatFeatureStore.blockedReason(item, imageInput: store.enabled.contains("image_input")) { Text(reason).font(.footnote).foregroundStyle(.secondary) } if selectedAttachmentIds.contains(item.id) { Label("Queued for your next message", systemImage: "checkmark.circle.fill").font(.footnote) Button("Remove from message") { onRemoveAttachment?(item.id) } } else { Button("Use in conversation", systemImage: "arrow.up.message") { Task { usingAttachment = true; defer { usingAttachment = false } guard let validated = await store.validatedAttachment(item) else { return } guard let onUseAttachment else { store.error = "Open Library from a conversation to use this file."; return } if await onUseAttachment(validated) { dismiss() } } }.disabled(usingAttachment || store.busy || onUseAttachment == nil || ChatFeatureStore.blockedReason(item, imageInput: store.enabled.contains("image_input")) != nil) .accessibilityHint("Queues this private file in your draft. Does not send a message.") } }.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteAttachment(item); if store.error == nil { onRemoveAttachment?(item.id) } } } } } Text("Select up to 8 files for the next message. Uploading alone does not send them.").font(.footnote).foregroundStyle(.secondary) } } if initialDestination == .files && store.enabled.contains("artifacts") { Section("Saved drafts") { Text("Documents you save here are separate from conversation history.").font(.footnote).foregroundStyle(.secondary) Button("New saved draft", systemImage: "doc.text") { artifact = nil; title = ""; content = ""; kind = "markdown"; artifactEditor = true } ForEach(store.artifacts) { item in Button { Task { if let detail = await store.detail(item) { artifact = detail; title = detail.title; kind = detail.kind; content = detail.content ?? ""; artifactEditor = true } } } label: { VStack(alignment: .leading) { Text(item.title); Text("Revision \(item.revision) · \(item.kind)").font(.caption).foregroundStyle(.secondary) } }.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteArtifact(item) } } } } } } if initialDestination == .tasks && store.enabled.contains("scheduled_tasks") { Section("Scheduled tasks") { Text("Tasks run on your Hope server. Push notifications and task output history are not available in this app.").font(.footnote).foregroundStyle(.secondary) Button("New scheduled task", systemImage: "calendar.badge.plus") { task = nil; name = ""; schedule = ""; prompt = ""; repeatCount = ""; createPaused = true; taskEditor = true } ForEach(store.tasks) { item in VStack(alignment: .leading) { Button(item.name ?? item.id) { task = item; name = item.name ?? ""; schedule = item.schedule ?? ""; prompt = item.prompt ?? ""; repeatCount = item.repeat.map(String.init) ?? ""; taskEditor = true }.frame(minHeight: 44) Text("\(item.enabled == true ? "Active" : item.enabled == false ? "Paused" : "Status not provided") · \(item.schedule ?? "Schedule not provided")").font(.caption).foregroundStyle(.secondary) HStack { Button(item.enabled == false ? "Resume" : "Pause") { Task { await store.taskAction(item, action: item.enabled == false ? "resume" : "pause") } }; Button("Run now") { Task { await store.taskAction(item, action: "run") } } } }.swipeActions { Button("Delete", role: .destructive) { deleting = { await store.deleteTask(item) } } } } } } if initialDestination == .memory && store.enabled.contains("memory_controls") { Section("Durable account memory") { Text("Guidance shapes future replies. About you stores lasting preferences and personal context. Neither is your conversation history.").font(.footnote).foregroundStyle(.secondary) ForEach(store.memory) { item in Button(item.section == "user" ? "About you" : "Guidance") { memory = item; memoryContent = item.content; latestMemory = nil; acceptedMemory = nil; memoryRequiresReview = false } } Text("Changes may take effect on the next Hermes run or session. Clearing memory does not delete chats or all account data.").font(.footnote).foregroundStyle(.secondary) } } } .navigationTitle(initialDestination == .files ? "Library" : initialDestination == .tasks ? "Scheduled tasks" : "Memory") .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } .task { await store.load(); loadingCapabilities = false } .fileImporter(isPresented: $importing, allowedContentTypes: [.plainText, .pdf, .png, .jpeg, UTType(filenameExtension: "md") ?? .plainText, UTType(filenameExtension: "webp") ?? .image]) { result in if case .success(let url) = result { Task { let scoped = url.startAccessingSecurityScopedResource(); defer { if scoped { url.stopAccessingSecurityScopedResource() } } do { let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 guard size <= 8 * 1024 * 1024 else { store.error = "Choose a file smaller than 8 MiB."; return } let mime: String switch url.pathExtension.lowercased() { case "md": mime = "text/markdown"; case "pdf": mime = "application/pdf"; case "png": mime = "image/png"; case "jpg", "jpeg": mime = "image/jpeg"; case "webp": mime = "image/webp"; default: mime = "text/plain" } if let uploaded = await store.upload(try Data(contentsOf: url), filename: url.lastPathComponent, mime: mime) { feedback = "\(uploaded.filename) uploaded privately. Tap Use in conversation to queue it." } } catch { store.error = "Could not read this file. Select it again to retry." } } } } .onChange(of: photo) { _, value in Task { if let data = try? await value?.loadTransferable(type: Data.self), data.count <= 8 * 1024 * 1024, let image = UIImage(data: data), let jpeg = image.jpegData(compressionQuality: 0.85) { if await store.upload(jpeg, filename: "photo.jpg", mime: "image/jpeg") != nil { feedback = "Photo uploaded privately. Tap Use in conversation to queue it." } } else if value != nil { store.error = "This photo could not be imported or exceeds 8 MiB. Choose a smaller JPEG or PNG." }; photo = nil } } .sheet(isPresented: $showingPreview, onDismiss: { store.clearPreview() }) { NavigationStack { ScrollView { if store.previewLoading { ProgressView("Opening private file…").frame(maxWidth: .infinity, minHeight: 200) } else if let error = store.previewError { ContentUnavailableView("Couldn’t open file", systemImage: "exclamationmark.triangle", description: Text(error)) if let item = store.previewAttachment { Button("Try again") { Task { await store.preview(item) } } } } else if let pdf = store.previewPDF { PrivatePDFPreview(data: pdf).frame(minHeight: 500) } else if let image = store.previewImage { Image(uiImage: image).resizable().scaledToFit().padding() } else if let text = store.previewText { Text(text).textSelection(.enabled).padding() } } .navigationTitle(store.previewAttachment?.filename ?? "Private preview") .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showingPreview = false } } } } } .sheet(isPresented: $artifactEditor) { artifactForm } .sheet(isPresented: $taskEditor) { taskForm } .sheet(item: $memory) { item in memoryForm(item) } .confirmationDialog("Delete this item?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible) { Button("Delete", role: .destructive) { if let deleting { Task { await deleting() } }; deleting = nil } } } } private var artifactForm: some View { NavigationStack { Form { TextField("Title", text: $title) Picker("Kind", selection: $kind) { ForEach(["markdown", "text", "code"], id: \.self) { Text($0.capitalized).tag($0) } } TextEditor(text: $content).frame(minHeight: 220) if let artifact { let file = PrivateChatExport(data: Data(content.utf8), filename: "Hope-artifact-\(artifact.id).\(kind == "markdown" ? "md" : "txt")", type: .plainText) ShareLink("Export file", item: file, preview: SharePreview(file.filename)) if artifact.revision > 1 { Menu("View earlier revision") { ForEach(1.. some View { NavigationStack { Form { Section("Your local draft") { TextEditor(text: $memoryContent).frame(minHeight: 220).accessibilityLabel("Memory draft").disabled(memoryWorking) Text("Your draft stays here while you compare server changes. Saving affects future runs; existing sessions may retain earlier memory.").font(.footnote).foregroundStyle(.secondary) } Section("Revision review") { Button("Compare latest server version") { Task { memoryWorking = true; defer { memoryWorking = false } if let value = await store.latestMemory(item) { latestMemory = value; acceptedMemory = nil; memoryRequiresReview = true } } }.disabled(memoryWorking) if let latestMemory { Text(latestMemory.revision == item.revision ? "The server revision matches the version you opened." : "The server has a newer revision. Review it before overwriting with your draft.").font(.footnote) Text(latestMemory.content.isEmpty ? "This section is empty on the server." : latestMemory.content).textSelection(.enabled) Button("Accept this revision for my next save") { acceptedMemory = latestMemory; memoryRequiresReview = false } .disabled(acceptedMemory?.revision == latestMemory.revision) if acceptedMemory != nil { Text("Revision accepted. Saving will write your local draft, not the server text.").font(.footnote) } } } Section { Button("Clear this memory section", role: .destructive) { confirmingClear = true }.disabled(memoryWorking || memoryRequiresReview) Text("Clearing removes only this durable section, not chats or all account data.").font(.footnote).foregroundStyle(.secondary) } if let error = store.error { Text(error).foregroundStyle(.red) } } .navigationTitle(item.section == "user" ? "About you" : "Guidance") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { memory = nil } } ToolbarItem(placement: .confirmationAction) { Button("Save") { if memoryContent.isEmpty { confirmingClear = true } else { Task { await writeMemory(acceptedMemory ?? item, content: memoryContent) } } }.disabled(memoryWorking || memoryRequiresReview) } } .confirmationDialog("Clear this memory section?", isPresented: $confirmingClear, titleVisibility: .visible) { Button("Clear section", role: .destructive) { Task { await writeMemory(acceptedMemory ?? item, content: "") } } } message: { Text("This removes durable memory only. Conversation history is unchanged.") } } } private func writeMemory(_ base: ChatMemory, content: String) async { memoryWorking = true; defer { memoryWorking = false } if await store.saveMemory(base, content: content) { memory = nil } else if store.error?.contains("Memory changed") == true { memoryRequiresReview = true; acceptedMemory = nil } } private var taskForm: some View { NavigationStack { Form { Section("Task") { TextField("Task name", text: $name) TextField("Schedule", text: $schedule).autocorrectionDisabled().textInputAutocapitalization(.never) Text("Enter a schedule accepted by your server, for example cron: 0 9 * * *. The server validates it; timezone and next run are not inferred here.").font(.footnote).foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 8) { Text("Instructions").font(.subheadline.weight(.semibold)) TextEditor(text: $prompt).frame(minHeight: 150).accessibilityLabel("Task instructions") Text("Tell Hope what to do when this task runs. For example: review my plans and suggest one practical next step.").font(.footnote).foregroundStyle(.secondary) } } Section("Options") { TextField("Repeat count (optional)", text: $repeatCount).keyboardType(.numberPad) Text("Leave blank for the server default, or enter a positive number of executions.").font(.footnote).foregroundStyle(.secondary) if task == nil { Toggle("Create paused", isOn: $createPaused) } Text("Tasks execute on your Hope server. This app does not deliver push notifications or show task output history.").font(.footnote).foregroundStyle(.secondary) } if let error = store.error { Text(error).foregroundStyle(.red) } } .navigationTitle(task == nil ? "New scheduled task" : "Edit task") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { taskEditor = false } } ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { let trimmed = repeatCount.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty && (Int(trimmed) == nil || (Int(trimmed) ?? 0) <= 0) { store.error = "Repeat count must be a positive whole number."; return } await store.saveTask(task, name: name, schedule: schedule, prompt: prompt, repeatCount: Int(trimmed), paused: createPaused) if store.error == nil { taskEditor = false } } }.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || schedule.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } } } } struct PrivatePDFPreview: UIViewRepresentable { let data: Data func makeUIView(context: Context) -> PDFView { let view = PDFView(); view.autoScales = true; view.document = PDFDocument(data: data); return view } func updateUIView(_ view: PDFView, context: Context) { if view.document?.dataRepresentation() != data { view.document = PDFDocument(data: data) } } }