Files
hapi/ios/Hapi/Features/Scratchlist/ScratchlistView.swift
T

608 lines
26 KiB
Swift

import HapiClient
import HapiProtocol
import HapiUI
import PhotosUI
import SwiftUI
/// Full inventory and a single navigation stack for reading/editing drafts.
struct ScratchlistView: View {
@State private var model: ScratchlistScreenModel
private let attachments: ScratchlistAttachmentLoader
private let interactor: ChatInteractor?
private let initialEntry: ScratchlistEntry?
private let initiallyEditing: Bool
@Environment(\.dismiss) private var dismiss
@State private var query = ""
@State private var path: [String] = []
@State private var viewerAttachment: ScratchlistAttachment?
@State private var discardAndClose = false
init(store: any SessionScratchlistStoring, sessionId: String, attachments: ScratchlistAttachmentLoader,
interactor: ChatInteractor? = nil, initialEntry: ScratchlistEntry? = nil, initiallyEditing: Bool = false) {
_model = State(initialValue: ScratchlistScreenModel(sessionId: sessionId, store: store))
self.attachments = attachments
self.interactor = interactor
self.initialEntry = initialEntry
self.initiallyEditing = initiallyEditing
}
private var entries: [ScratchlistEntry] {
let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
return model.state.entries.filter {
query.isEmpty || $0.text.localizedStandardContains(query)
|| $0.attachments.contains { $0.filename.localizedStandardContains(query) }
}
}
var body: some View {
NavigationStack(path: $path) {
inventory
.navigationTitle("Scratchlist")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $query, placement: .navigationBarDrawer(displayMode: .automatic), prompt: "Search drafts")
.toolbar {
ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } }
}
.navigationDestination(for: String.self) { entryId in
ScratchlistDetailView(model: model, entryId: entryId, loader: attachments,
interactor: interactor, onRestore: { dismiss() },
onOpenAttachment: { viewerAttachment = $0 })
}
}
.presentationDetents([.large])
.presentationDragIndicator(.visible)
.background(ScratchlistDismissGuard(blocked: model.editor?.isDirty == true || model.editor?.isSaving == true) {
if model.editor?.isSaving != true { discardAndClose = true }
})
.confirmationDialog("Discard your changes?", isPresented: $discardAndClose, titleVisibility: .visible) {
Button("Discard changes", role: .destructive) { model.dismissEditor(); dismiss() }
Button("Keep editing", role: .cancel) {}
}
.fullScreenCover(item: $viewerAttachment) { attachment in
ScratchlistAttachmentViewer(attachment: attachment, loader: attachments)
}
.onAppear {
model.start()
if path.isEmpty, let initialEntry {
path = [initialEntry.entryId]
if initiallyEditing { model.openEditor(initialEntry) }
}
}
.onDisappear {
model.stop()
if viewerAttachment == nil { model.dismissEditor() }
}
}
@ViewBuilder
private var inventory: some View {
if model.isLoading {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
} else if model.state.loadFailed {
ContentUnavailableView {
Label("Couldn't load the scratchlist", systemImage: "wifi.slash")
} description: {
Text("Check the connection to your hub and try again.")
} actions: { Button("Retry") { model.retry() }.buttonStyle(.borderedProminent) }
} else {
List {
Section {
if let notice = model.notice {
ScratchlistErrorBanner(message: notice, actionTitle: "Dismiss") { model.clearNotice() }
}
if model.state.refreshFailed {
ScratchlistErrorBanner(message: String(localized: "Couldn't refresh — showing saved drafts")) { model.retry() }
}
if model.state.atCap {
Text("Scratchlist is full (200 entries)").font(.footnote).foregroundStyle(.secondary)
}
ForEach(entries) { entry in
ScratchlistEntryRow(entry: entry, interactor: interactor,
onOpen: { path.append(entry.entryId) },
onEdit: { model.openEditor(entry); path.append(entry.entryId) },
onDelete: { model.deleteEntry(entry.entryId) }, onRestore: { dismiss() })
.disabled(model.deletingEntryId != nil)
}
}
}
.listStyle(.insetGrouped)
.overlay {
if model.state.entries.isEmpty {
ContentUnavailableView {
Label("No drafts yet", systemImage: "tray")
} actions: {
if let interactor {
Button("Write draft") {
interactor.setComposerDestination(.scratchlist)
interactor.focusComposer()
dismiss()
}.buttonStyle(.borderedProminent)
}
}
} else if entries.isEmpty {
ContentUnavailableView.search(text: query)
}
}
.refreshable { await model.refresh() }
}
}
}
/// Each affordance is a real button: tapping a row never also takes a draft.
struct ScratchlistEntryRow: View {
@Environment(\.hapiTypography) private var typography
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
let entry: ScratchlistEntry
let interactor: ChatInteractor?
let onOpen: () -> Void
let onEdit: () -> Void
let onDelete: () -> Void
var onRestore: () -> Void = {}
var compact = false
var showsPreview = true
var showsEditAction = true
@State private var confirmRestore = false
@State private var confirmDelete = false
@State private var restoreError: String?
private var queued: Bool { interactor?.queuedScratchlistEntries.contains(entry.entryId) == true }
private var busy: Bool { interactor?.scratchlistBusy == true || interactor?.isSending == true }
// The compact row shares the composer's error banner. Do not repeat a
// failed park/restore there; the sheet needs its own local feedback.
private var error: String? {
(compact ? nil : restoreError) ?? interactor?.scratchlistEntryErrors[entry.entryId]
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
if dynamicTypeSize.isAccessibilitySize {
if showsPreview { preview }
actions.frame(maxWidth: .infinity, alignment: .trailing)
} else {
HStack(alignment: .center, spacing: 12) {
if showsPreview { preview }
else { Spacer(minLength: 0) }
actions
}
}
if let error {
Text(LocalizedNoticeMapper.map(error)).font(.footnote).foregroundStyle(.red)
}
}
.confirmationDialog("You already have a draft", isPresented: $confirmRestore, titleVisibility: .visible) {
Button("Append to input") { restore(.append) }
Button("Save input, then take draft") { restore(.parkCurrent) }
Button("Cancel", role: .cancel) {}
}
.confirmationDialog("Delete this draft?", isPresented: $confirmDelete, titleVisibility: .visible) {
Button("Delete", role: .destructive, action: onDelete)
Button("Cancel", role: .cancel) {}
}
}
private var preview: some View {
Button(action: onOpen) {
VStack(alignment: .leading, spacing: 5) {
Text(entry.text.isEmpty ? String(localized: "Attachment only") : entry.text)
.font(typography.bodyFont)
.foregroundStyle(.primary)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
if !compact, !entry.attachments.isEmpty {
Label {
Text(verbatim: "\(entry.attachments.count)")
} icon: { Image(systemName: "paperclip") }
.font(typography.captionFont).foregroundStyle(.secondary)
.accessibilityLabel(Text("\(entry.attachments.count) attachments"))
}
}
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint("View the full draft")
.accessibilityIdentifier("scratchlist.preview.\(entry.entryId)")
}
private var actions: some View {
HStack(spacing: 8) {
if let interactor {
Button {
if queued { queue() }
else if interactor.hasComposerDraft { confirmRestore = true }
else { restore(.append) }
} label: {
Text(queued ? "Retry removal" : "Take draft")
.font(.subheadline.weight(.medium))
.frame(minWidth: 44, minHeight: 44)
.contentShape(Rectangle())
}
.accessibilityIdentifier("scratchlist.take.\(entry.entryId)")
.disabled(busy)
}
if !compact {
Menu {
if interactor != nil, !queued {
Button("Add to send queue", systemImage: "text.line.first.and.arrowtriangle.forward", action: queue)
}
if showsEditAction {
Button("Edit", systemImage: "pencil", action: onEdit).disabled(queued)
}
Button("Copy text", systemImage: "doc.on.doc") { UIPasteboard.general.string = entry.text }
.disabled(entry.text.isEmpty)
Button("Delete", systemImage: "trash", role: .destructive) { confirmDelete = true }.disabled(queued)
} label: {
Image(systemName: "ellipsis")
.font(.system(size: 18, weight: .medium))
.frame(width: 44, height: 44).contentShape(Rectangle())
}
.accessibilityLabel("Draft actions")
.accessibilityIdentifier("scratchlist.actions.\(entry.entryId)")
.disabled(busy)
}
}
.buttonStyle(.plain)
.foregroundStyle(.tint)
}
private func queue() {
guard let interactor else { return }
restoreError = nil
Task { if await interactor.queueScratchlistEntry(entry) { onRestore() } }
}
private func restore(_ choice: ScratchlistRestoreChoice) {
guard let interactor else { return }
restoreError = nil
Task {
if await interactor.restoreScratchlistEntry(entry, choice: choice) { onRestore() }
else { restoreError = interactor.scratchlistError }
}
}
}
struct ScratchlistErrorBanner: View {
let message: String
var actionTitle: LocalizedStringKey = "Retry"
let retry: () -> Void
var body: some View {
HStack(alignment: .center, spacing: 8) {
Image(systemName: "exclamationmark.circle").foregroundStyle(.red)
Text(LocalizedNoticeMapper.map(message)).font(.footnote).frame(maxWidth: .infinity, alignment: .leading)
Button(action: retry) {
Text(actionTitle).frame(minHeight: 44).contentShape(Rectangle())
}
}
.accessibilityElement(children: .contain)
}
}
private struct ScratchlistAgeLabel: View {
let updatedAt: Int
@Environment(\.locale) private var locale
var body: some View {
TimelineView(.periodic(from: .now, by: 60)) { context in
Text(verbatim: age(relativeTo: context.date))
}
}
private func age(relativeTo now: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.locale = locale
formatter.unitsStyle = .short
return formatter.localizedString(for: Date(timeIntervalSince1970: Double(updatedAt) / 1000), relativeTo: now)
}
}
private struct ScratchlistDetailView: View {
let model: ScratchlistScreenModel
let entryId: String
let loader: ScratchlistAttachmentLoader
let interactor: ChatInteractor?
let onRestore: () -> Void
let onOpenAttachment: (ScratchlistAttachment) -> Void
@Environment(\.hapiTypography) private var typography
@State private var discardChanges = false
private var entry: ScratchlistEntry? { model.state.entries.first { $0.entryId == entryId } }
var body: some View {
Group {
if model.editor != nil {
ScratchlistEditorView(model: model, attachments: loader, onOpenAttachment: onOpenAttachment)
} else if let entry {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
if let notice = model.notice {
ScratchlistErrorBanner(message: notice, actionTitle: "Dismiss") { model.clearNotice() }
}
Text(entry.text).font(typography.bodyFont).textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
if !entry.attachments.isEmpty {
ScratchlistAttachmentStrip(attachments: entry.attachments, loader: loader,
thumbSize: 88, onOpen: onOpenAttachment, onRemove: nil, showsFilenames: true)
}
ScratchlistAgeLabel(updatedAt: entry.updatedAt)
.font(typography.captionFont).foregroundStyle(.secondary)
ScratchlistEntryRow(entry: entry, interactor: interactor, onOpen: {},
onEdit: { model.openEditor(entry) }, onDelete: { model.deleteEntry(entryId) }, onRestore: onRestore,
showsPreview: false, showsEditAction: false)
}.padding(16)
}
} else {
ContentUnavailableView("Draft no longer available", systemImage: "tray")
}
}
.navigationTitle(model.editor == nil ? "Draft" : "Edit draft")
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(model.editor != nil)
.toolbar {
if let editor = model.editor {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
if editor.isDirty { discardChanges = true } else { model.dismissEditor() }
}.disabled(editor.isSaving)
}
ToolbarItem(placement: .topBarTrailing) {
Button("Save") { model.saveEditor() }.disabled(!editor.canSave)
}
} else if let entry {
ToolbarItem(placement: .topBarTrailing) {
Button("Edit") { model.openEditor(entry) }
.disabled(interactor?.scratchlistBusy == true || interactor?.queuedScratchlistEntries.contains(entryId) == true)
}
}
}
.confirmationDialog("Discard your changes?", isPresented: $discardChanges, titleVisibility: .visible) {
Button("Discard changes", role: .destructive) { model.dismissEditor() }
Button("Keep editing", role: .cancel) {}
}
}
}
private struct ScratchlistEditorView: View {
let model: ScratchlistScreenModel
let attachments: ScratchlistAttachmentLoader
let onOpenAttachment: (ScratchlistAttachment) -> Void
@Environment(\.hapiTypography) private var typography
@State private var pickedItem: PhotosPickerItem?
@State private var photosPickerOpen = false
@State private var filePickerOpen = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
if let editor = model.editor {
if editor.isSaving { ProgressView().frame(maxWidth: .infinity) }
if let error = editor.error {
ScratchlistErrorBanner(message: error, actionTitle: model.editorErrorIsRetryable ? "Retry" : "Dismiss",
retry: model.retryEditorOperation)
}
if editor.text.utf16.count > ScratchlistCaps.maxTextLength {
Text("Drafts can contain at most 10,000 characters — shorten the text before saving")
.font(.footnote).foregroundStyle(.red)
}
TextEditor(text: Binding(get: { model.editor?.text ?? "" }, set: model.setEditorText))
.font(typography.bodyFont)
.accessibilityLabel("Draft text")
.disabled(editor.isSaving)
if !editor.attachments.isEmpty {
ScratchlistAttachmentStrip(attachments: editor.attachments, loader: attachments, thumbSize: 72,
onOpen: onOpenAttachment, onRemove: model.removeAttachment)
.frame(height: 78)
.disabled(editor.isSaving || editor.isUploading)
}
if let filename = editor.failedUploadName {
HStack {
Label(filename, systemImage: "exclamationmark.circle").font(.footnote).foregroundStyle(.red)
Spacer()
Button("Remove", role: .destructive) { model.removeFailedUpload() }.frame(minHeight: 44)
}.disabled(editor.isUploading)
}
HStack {
Menu {
Button("Photo library", systemImage: "photo") { photosPickerOpen = true }
Button("Files", systemImage: "doc") { filePickerOpen = true }
} label: {
Image(systemName: "plus")
.frame(width: 44, height: 44).contentShape(Rectangle())
}
.accessibilityLabel("Add attachment")
.accessibilityIdentifier("scratchlist.editor.attach")
Spacer()
if editor.isUploading { ProgressView() }
}
.frame(minHeight: 44)
.disabled(editor.isSaving || editor.isUploading || editor.failedUploadName != nil)
}
}
.padding(16)
.photosPicker(isPresented: $photosPickerOpen, selection: $pickedItem, matching: .images)
.onChange(of: pickedItem) {
guard let item = pickedItem else { return }
pickedItem = nil
model.addAttachment(item)
}
.fileImporter(isPresented: $filePickerOpen, allowedContentTypes: [.item]) { result in
guard case .success(let url) = result else { return }
let editorId = model.editor?.id
Task {
let prepared = await AttachmentPreparer.prepare(fileURL: url)
guard model.editor?.id == editorId else { return }
switch prepared {
case .ready(let prepared):
model.addPreparedAttachment(prepared)
case .tooLarge(let filename, _):
model.reportEditorError(String(format: String(localized: "%@ is over the 50 MB upload limit"), filename))
case .unreadable(let filename):
model.reportEditorError(String(format: String(localized: "Couldn't read %@"), filename))
}
}
}
}
}
// MARK: - Attachment strip
/// Horizontal thumbnails: images render through the authed loader, other
/// mime types (pdf/text) degrade to filename chips; an optional ✕ badge
/// removes (editor strip).
struct ScratchlistAttachmentStrip: View {
let attachments: [ScratchlistAttachment]
let loader: ScratchlistAttachmentLoader
let thumbSize: CGFloat
let onOpen: (ScratchlistAttachment) -> Void
let onRemove: ((ScratchlistAttachment) -> Void)?
var showsFilenames = false
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: showsFilenames ? 12 : 6) {
ForEach(attachments) { attachment in
VStack(spacing: 6) {
ScratchlistAttachmentThumb(
attachment: attachment,
loader: loader,
size: thumbSize,
onOpen: { onOpen(attachment) },
onRemove: removeAction(for: attachment),
showsFilename: !showsFilenames
)
if showsFilenames {
Text(verbatim: attachment.filename)
.font(.caption).foregroundStyle(.secondary)
.frame(width: max(thumbSize, 140))
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
}
}
private func removeAction(for attachment: ScratchlistAttachment) -> (() -> Void)? {
guard let onRemove else { return nil }
return { onRemove(attachment) }
}
}
/// One thumbnail (image via the authed loader, otherwise a filename chip).
struct ScratchlistAttachmentThumb: View {
let attachment: ScratchlistAttachment
let loader: ScratchlistAttachmentLoader
let size: CGFloat
let onOpen: () -> Void
let onRemove: (() -> Void)?
var showsFilename = true
@State private var image: UIImage?
@State private var failed = false
private var isImage: Bool {
attachment.mimeType.hasPrefix("image/")
}
var body: some View {
ZStack(alignment: .topTrailing) {
Button(action: onOpen) {
tile
}
.buttonStyle(.plain)
.accessibilityLabel(attachment.filename)
if let onRemove {
Button(action: onRemove) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 16))
.foregroundStyle(.white, .black.opacity(0.6))
}
.buttonStyle(.plain)
.frame(minWidth: 44, minHeight: 44)
.accessibilityLabel("Remove \(attachment.filename)")
}
}
.task(id: attachment.id) {
guard isImage, image == nil, !failed else { return }
if let loaded = await loader.image(for: attachment.id) {
image = loaded
} else {
failed = true
}
}
}
@ViewBuilder
private var tile: some View {
if isImage, !failed {
Group {
if let image {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else {
ProgressView()
.controlSize(.small)
}
}
.frame(width: size, height: size)
.background(Color(uiColor: .tertiarySystemFill))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
} else {
Group {
if showsFilename {
Text(verbatim: "📎 \(attachment.filename)")
.font(.caption2).lineLimit(3).multilineTextAlignment(.center)
} else {
Image(systemName: isImage ? "photo" : "doc")
.font(.title2).foregroundStyle(.secondary)
}
}
.padding(4)
.frame(width: size, height: size)
.background(Color(uiColor: .tertiarySystemFill), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
}
// MARK: - Viewer
/// Full-screen attachment viewer (the generated-image viewer pattern): dark
/// backdrop, fit-scaled image via the authed loader, tap or the close button
/// dismisses. Non-image attachments show a filename placeholder.
struct ScratchlistAttachmentViewer: View {
let attachment: ScratchlistAttachment
let loader: ScratchlistAttachmentLoader
@Environment(\.dismiss) private var dismiss
@State private var image: UIImage?
var body: some View {
ZStack(alignment: .topTrailing) {
Color.black.ignoresSafeArea()
if let image {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(8)
} else {
Text(attachment.filename)
.foregroundStyle(.white)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.white.opacity(0.8))
.padding(16)
}
}
.onTapGesture {
dismiss()
}
.task {
guard attachment.mimeType.hasPrefix("image/") else { return }
image = await loader.image(for: attachment.id)
}
}
}