Files
hapi/ios/Hapi/Features/Scratchlist/ScratchlistScreenModel.swift
T
weishu c6f1d27c76 fix(ios): import SwiftUI alongside PhotosUI for the cross-import overlay
PhotosPickerItem/PhotosPicker live in the _PhotosUI_SwiftUI overlay module,
visible only when BOTH PhotosUI and SwiftUI are imported in the same file —
the app's sole compile error on the first real Xcode build.
2026-08-18 16:34:29 +08:00

294 lines
11 KiB
Swift

import Foundation
import HapiClient
import HapiProtocol
import Observation
import PhotosUI
// PhotosPickerItem lives in the `_PhotosUI_SwiftUI` cross-import overlay:
// it is only visible when SwiftUI is imported alongside PhotosUI.
import SwiftUI
import UniformTypeIdentifiers
/// Edit-sheet model; `entryId == nil` = drafting a brand-new entry.
struct ScratchlistEditorState: Equatable {
var entryId: String?
var text = ""
var attachments: [ScratchlistAttachment] = []
/// A picked file is importing/uploading — spinner tile in the strip.
var isUploading = false
}
/// Per-session scratchlist workbench (A-M4b) — the iOS port of the Android
/// `ScratchlistViewModel`: the entries list rides
/// ``SessionScratchlistStoring``'s per-session cache (optimistic CRUD +
/// SSE-triggered refetch handled there); this model owns the edit-sheet draft
/// state and the attachment import→upload flow.
///
/// Attachment writes: on an **existing** entry every strip change persists
/// immediately (upload → `PUT {attachments}` / remove → PUT minus the file,
/// then a best-effort attachment delete to free session bytes). On a **new**
/// entry uploads accumulate locally and travel with the create; dismissing
/// the draft best-effort deletes the now-orphaned uploads.
@MainActor @Observable
final class ScratchlistScreenModel {
let sessionId: String
@ObservationIgnored private let store: any SessionScratchlistStoring
/// Non-nil while the edit sheet is open.
private(set) var editor: ScratchlistEditorState?
/// Transient failure/notice toast (the Android snackbar analogue).
private(set) var notice: String?
@ObservationIgnored private var noticeTask: Task<Void, Never>?
@ObservationIgnored private var opened = false
init(sessionId: String, store: any SessionScratchlistStoring) {
self.sessionId = sessionId
self.store = store
}
// MARK: - List state (from the store)
var state: ScratchlistSessionState { store.state(sessionId) }
/// First fetch still running, nothing cached yet.
var isLoading: Bool { !state.loaded && !state.loadFailed }
// MARK: - Lifecycle (paired with the screen's appear/disappear)
func start() {
guard !opened else { return }
opened = true
store.open(sessionId)
}
func stop() {
guard opened else { return }
opened = false
noticeTask?.cancel()
store.release(sessionId)
}
/// Error-state retry.
func retry() {
Task { [self] in
try? await store.refresh(sessionId)
}
}
// MARK: - Editor
/// Card tap (existing) or the new-note button (`entry == nil`, new draft).
func openEditor(_ entry: ScratchlistEntry?) {
if entry == nil, state.atCap {
showNotice(String(localized: "Scratchlist is full (200 entries) — delete one first"))
return
}
if let entry {
editor = ScratchlistEditorState(
entryId: entry.entryId,
text: entry.text,
attachments: entry.attachments
)
} else {
editor = ScratchlistEditorState()
}
}
/// Sheet dismissed without saving; orphaned new-draft uploads are freed.
func dismissEditor() {
guard let editor else { return }
self.editor = nil
if editor.entryId == nil, !editor.attachments.isEmpty {
Task { [self] in
for attachment in editor.attachments {
_ = await store.deleteAttachment(sessionId: sessionId, attachmentId: attachment.id)
}
}
}
}
func setEditorText(_ text: String) {
editor?.text = text
}
/// Save closes the sheet immediately (mutations are store-optimistic and
/// roll back with a notice on failure — web parity).
func saveEditor() {
guard let editor else { return }
let text = editor.text.trimmingCharacters(in: .whitespacesAndNewlines)
if text.isEmpty, editor.attachments.isEmpty {
// Nothing to keep: a new draft just closes; an existing entry
// must keep text or attachments (hub 400s empty updates).
if editor.entryId == nil {
self.editor = nil
} else {
showNotice("A note needs text or an attachment — use Delete to remove it")
}
return
}
self.editor = nil
Task { [self] in
if let entryId = editor.entryId {
let updated = await store.updateEntry(
sessionId: sessionId,
entryId: entryId,
text: text,
attachments: nil
)
if !updated {
showNotice(String(localized: "Couldn't save the note — check the hub connection"))
}
} else {
switch await store.createEntry(sessionId: sessionId, text: text, attachments: editor.attachments) {
case .created:
break
case .atCap:
showNotice(String(localized: "Scratchlist is full (200 entries)"))
case .failed:
showNotice(String(localized: "Couldn't save the note — check the hub connection"))
}
}
}
}
/// Sheet delete; optimistic with store rollback.
func deleteEntry(_ entryId: String) {
if editor?.entryId == entryId {
editor = nil
}
Task { [self] in
let deleted = await store.deleteEntry(sessionId: sessionId, entryId: entryId)
if !deleted {
showNotice(String(localized: "Couldn't delete the note — check the hub connection"))
}
}
}
// MARK: - Attachments
/// Photo-picker result → guard/downscale → upload → strip (and PUT for
/// existing entries).
func addAttachment(_ item: PhotosPickerItem) {
guard let current = editor, !current.isUploading else { return }
editor?.isUploading = true
Task { [weak self] in
guard let self else { return }
defer { self.editor?.isUploading = false }
let limits = await self.store.limits(sessionId: self.sessionId)
guard let existing = self.editor?.attachments else { return }
guard let data = try? await item.loadTransferable(type: Data.self), !data.isEmpty else {
self.showNotice(String(localized: "Couldn't read the selected photo"))
return
}
let contentType = item.supportedContentTypes.first
let mimeType = contentType?.preferredMIMEType ?? "application/octet-stream"
let filename = Self.pickedFilename(for: contentType)
// Guard + downscale are CPU work — keep them off the main actor.
let outcome = await Task.detached(priority: .userInitiated) {
ScratchlistAttachmentImport.prepare(
data: data,
filename: filename,
mimeType: mimeType,
existing: existing,
limits: limits
)
}.value
switch outcome {
case .rejected(let message):
self.showNotice(message)
case .ready(let prepared):
let uploaded = await self.store.uploadAttachment(
sessionId: self.sessionId,
filename: prepared.filename,
data: prepared.data,
mimeType: prepared.mimeType
)
switch uploaded {
case .failed(_, let code):
self.showNotice(Self.uploadFailureMessage(code: code))
case .uploaded(let attachment):
await self.attachToEditor(attachment)
}
}
}
}
private func attachToEditor(_ attachment: ScratchlistAttachment) async {
guard let editor else {
// Sheet closed mid-upload: don't leak the stored file.
_ = await store.deleteAttachment(sessionId: sessionId, attachmentId: attachment.id)
return
}
let next = editor.attachments + [attachment]
self.editor?.attachments = next
if let entryId = editor.entryId {
let updated = await store.updateEntry(
sessionId: sessionId,
entryId: entryId,
text: nil,
attachments: next
)
if !updated {
rollbackEditorAttachments(entryId: entryId, to: editor.attachments)
_ = await store.deleteAttachment(sessionId: sessionId, attachmentId: attachment.id)
showNotice(String(localized: "Couldn't attach the file — check the hub connection"))
}
}
}
/// Restore the strip to `attachments` if the sheet still edits `entryId`.
private func rollbackEditorAttachments(entryId: String, to attachments: [ScratchlistAttachment]) {
guard editor?.entryId == entryId else { return }
editor?.attachments = attachments
}
/// Strip ✕: detach (existing entries PUT immediately) and free the stored file.
func removeAttachment(_ attachment: ScratchlistAttachment) {
guard let editor else { return }
let next = editor.attachments.filter { $0.id != attachment.id }
guard next.count != editor.attachments.count else { return }
self.editor?.attachments = next
Task { [self] in
if let entryId = editor.entryId {
let updated = await store.updateEntry(
sessionId: sessionId,
entryId: entryId,
text: nil,
attachments: next
)
if !updated {
rollbackEditorAttachments(entryId: entryId, to: editor.attachments)
showNotice(String(localized: "Couldn't remove the attachment — check the hub connection"))
return
}
}
// Best-effort byte-budget cleanup; InUse just means another entry
// still references the file (fine to leave).
_ = await store.deleteAttachment(sessionId: sessionId, attachmentId: attachment.id)
}
}
// MARK: - Internals
private static func uploadFailureMessage(code: String?) -> String {
code == ScratchlistErrorCode.attachmentTooLarge
? String(localized: "That file is over the hub's per-file size limit")
: String(localized: "Upload failed — check the hub connection")
}
private static func pickedFilename(for contentType: UTType?) -> String {
let stamp = Int(Date().timeIntervalSince1970 * 1000)
let ext = contentType?.preferredFilenameExtension ?? "bin"
return "photo-\(stamp).\(ext)"
}
private func showNotice(_ message: String) {
notice = message
noticeTask?.cancel()
noticeTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(4))
guard !Task.isCancelled else { return }
self?.notice = nil
}
}
}