Files
hapi/ios/Hapi/Features/Scratchlist/ScratchlistScreenModel.swift
T
weishu 6c7746d2b0 feat(ios): scratchlist (A-M4b)
Per-session parked notes mirroring the Android B-M4d feature:

- HapiProtocol Models/ScratchlistApi.swift: wire types for entries CRUD
  (idempotent create via client entryId+createdAt), attachment metadata,
  limits (defaults from shared/src/scratchlistAttachments.ts), upload
  envelope, and the typed error codes (scratchlist_at_cap,
  scratchlist_attachment_too_large, scratchlist_attachment_in_use, ...).
- Endpoints/ScratchlistEndpoints.swift: GET/POST/PUT/DELETE entries,
  limits, base64 upload, raw-bytes attachment fetch, attachment delete.
- Stores/ScratchlistStore.swift: @MainActor @Observable per-session cache
  behind the SessionScratchlistStoring seam - open/release observation,
  16 ms-coalesced refetch on the scratchlistUpdatedAt SSE signal,
  optimistic create/update/delete with surgical entryId reconcile +
  rollback (refresh preserves in-flight optimistic creates), 200-entry cap
  pre-check + hub 409 verdict, uploadsInFlight, cached limits with
  offline defaults, UTF-16 text clamp at 10000.
- Stores/ScratchlistAttachmentGuard.swift: pure Fits/Downscale/Reject
  budget verdicts ported verbatim.
- SessionListStore: onScratchlistInvalidation callback fired when a
  session patch carries scratchlistUpdatedAt (the seam the M4b comment
  reserved); HubSession wires it into the store and injects the store
  into ChatInteractor.
- ChatInteractor (additive tail section): scratchlist store property,
  scratchlistCount badge seam, insertComposerText, parkComposerDraft
  (composer clears only after the hub accepts; at-cap/failed keep the
  draft).
- Features/Scratchlist/: sheet off the chat toolbar's note icon with
  count badge - entry cards (4-line preview, relative age, authed
  thumbnails via NSCache loader, filename chips), edit sheet
  (PhotosPicker -> guard -> JPEG downscale -> upload spinner tile,
  remove, delete/save), full-screen viewer (GeneratedImage pattern),
  per-entry To composer, and Park current draft in the screen header (a
  deliberate placement divergence from Android's composer button - the
  composer UI is owned by the concurrent attachments package). iOS-only
  import step transcodes disallowed-but-decodable rasters (HEIC) to JPEG
  before the guard.
- Tests (36, all transcribed from or mirroring the Android suites): store
  CRUD optimistic/rollback/at-cap/invalidation/upload/limits with
  canonical wire-body asserts + the SessionListStore seam test, 9 guard
  verdicts, 9 interactor park/insert/badge seam tests over a fake store.

Verified on Linux via a dockerized Swift 6.0 scratch copy (FIFO HTTP
performer): full HapiClient build under strict concurrency, 36/36 new
tests green, SessionListStore 17/17 and ChatInteractor 29/29 suites
green; app-side screen model + loader typechecked against stub
frameworks; SwiftUI views and the CG-based import parse-checked (macOS
CI compiles them).
2026-08-18 11:38:37 +08:00

291 lines
11 KiB
Swift

import Foundation
import HapiClient
import HapiProtocol
import Observation
import PhotosUI
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("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("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("Scratchlist is full (200 entries)")
case .failed:
showNotice("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("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("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("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("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
? "That file is over the hub's per-file size limit"
: "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
}
}
}