Files
hapi/ios/Hapi/Features/Chat/Composer/ChatComposerView.swift
T
weishu 10042f79c6 feat(ios): refine chat typography and reading layout
Use unified 16pt Dynamic Type body text, clearer Markdown spacing, and a shared reading column. Preserve transcript anchors across typography changes and bound tool command previews to two lines. Add layout and typography regression tests.
2026-09-10 16:22:36 +08:00

535 lines
19 KiB
Swift

import AVFAudio
import HapiClient
import HapiUI
import PhotosUI
import SwiftUI
import UIKit
import UniformTypeIdentifiers
/// The chat input card (A-M3a, extended in A-M3f): attachments and the
/// multiline text field grow above a fixed bottom action row. The trailing
/// primary action is stateful: Stop while a turn runs with an empty draft,
/// otherwise Send (long-press offers "Send & steer" during a turn). The card
/// also hosts dictation and attachment controls.
struct ChatComposerView: View {
@Environment(\.hapiTypography) private var typography
let interactor: ChatInteractor
/// nil ⇒ dictation unavailable (no controller wired) — mic button hidden.
var dictation: DictationController?
// Attachment pickers (the launchers live here; policy + upload live in
// HapiKit — `AttachmentPreparer` / `ComposerAttachments`).
@State private var attachDialogOpen = false
@State private var photosPickerOpen = false
@State private var photoSelection: [PhotosPickerItem] = []
@State private var cameraOpen = false
@State private var filePickerOpen = false
private var text: Binding<String> {
Binding(
get: { interactor.composerText },
set: { interactor.setComposerText($0) }
)
}
/// Elevated iOS surface: white in light mode, lifted charcoal in dark
/// mode. `secondarySystemBackground` made the whole composer read like a
/// grouped form cell instead of a floating iOS control.
private var composerSurfaceColor: Color {
Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(white: 0.12, alpha: 1)
: .white
})
}
var body: some View {
let composer = interactor.composer
let attachments = interactor.attachments.items
VStack(spacing: 0) {
VStack(spacing: 0) {
if !attachments.isEmpty {
attachmentsRow(attachments)
.padding(.horizontal, 12)
.padding(.top, 8)
}
if let dictation, case .recording(let startedAtMs) = dictation.state {
RecordingChipView(startedAtMs: startedAtMs) {
dictation.cancel()
}
.padding(.horizontal, 12)
.padding(.top, 8)
}
TextField("Message the agent…", text: text, axis: .vertical)
.font(typography.bodyFont)
.lineLimit(1...6)
.textFieldStyle(.plain)
.padding(.horizontal, 12)
.padding(.top, 12)
.padding(.bottom, 4)
HStack(spacing: 2) {
addAttachmentButton
Spacer()
if let dictation, dictation.isAvailable {
micButton(dictation)
}
primaryActionButton(composer, attachments: attachments)
}
// 44 pt touch slot with a centered 38 pt circle:
// 9 + 3 = the shared 12 pt visual inset.
.padding(.horizontal, 9)
.padding(.bottom, 4)
}
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(composerSurfaceColor)
)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.shadow(color: .black.opacity(0.06), radius: 6, y: 2)
}
.hapiReadingColumn()
.padding(.vertical, 8)
.task(id: dictation.map { ObjectIdentifier($0) }) {
await dictation?.refreshAvailability()
}
.confirmationDialog("Attach", isPresented: $attachDialogOpen, titleVisibility: .visible) {
Button("Photo library") {
photosPickerOpen = true
}
if CameraCaptureView.isAvailable {
Button("Camera") {
cameraOpen = true
}
}
Button("Files") {
filePickerOpen = true
}
}
.photosPicker(
isPresented: $photosPickerOpen,
selection: $photoSelection,
matching: .any(of: [.images, .videos])
)
.onChange(of: photoSelection) {
let items = photoSelection
guard !items.isEmpty else { return }
photoSelection = []
Task {
for item in items {
await ingestPhotoItem(item)
}
}
}
.fullScreenCover(isPresented: $cameraOpen) {
CameraCaptureView { jpeg in
Task {
await handle(AttachmentPreparer.prepare(
data: jpeg,
filename: AttachmentPreparer.cameraFilename(),
mimeType: "image/jpeg"
))
}
}
.ignoresSafeArea()
}
.fileImporter(
isPresented: $filePickerOpen,
allowedContentTypes: [.item],
allowsMultipleSelection: true
) { outcome in
guard case .success(let urls) = outcome else { return }
Task {
for url in urls {
await handle(AttachmentPreparer.prepare(fileURL: url))
}
}
}
}
// MARK: - Attachment ingestion
private func ingestPhotoItem(_ item: PhotosPickerItem) async {
let type = item.supportedContentTypes.first
if type?.conforms(to: .audiovisualContent) == true {
// Videos import as a temp file (capped read in the preparer) so a
// multi-GB pick never has to fit in memory.
guard let movie = try? await item.loadTransferable(type: PickedMovie.self) else {
interactor.postNotice(String(localized: "Couldn't read the selected video"))
return
}
let result = await AttachmentPreparer.prepare(fileURL: movie.url)
try? FileManager.default.removeItem(at: movie.url)
handle(result)
return
}
let naming = AttachmentPreparer.photoFilename(for: type)
guard let data = try? await item.loadTransferable(type: Data.self) else {
interactor.postNotice(String(format: String(localized: "Couldn't read %@"), naming.filename))
return
}
await handle(AttachmentPreparer.prepare(
data: data,
filename: naming.filename,
mimeType: naming.mimeType
))
}
private func handle(_ result: PrepareResult) {
switch result {
case .ready(let prepared):
interactor.attachments.add(prepared)
case .tooLarge(let filename, _):
interactor.postNotice(String(format: String(localized: "%@ is over the 50 MB upload limit"), filename))
case .unreadable(let filename):
interactor.postNotice(String(format: String(localized: "Couldn't read %@"), filename))
}
}
// MARK: - Attachment chips
private func attachmentsRow(_ attachments: [ComposerAttachmentUI]) -> some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(attachments) { attachment in
ComposerAttachmentChipView(
attachment: attachment,
onRetry: { interactor.attachments.retry(attachment.id) },
onRemove: { interactor.attachments.remove(attachment.id) }
)
}
}
}
}
private var addAttachmentButton: some View {
Button {
attachDialogOpen = true
} label: {
actionCircle(
background: AnyShapeStyle(.fill.tertiary),
foreground: AnyShapeStyle(.secondary)
) {
Image(systemName: "plus")
.font(.system(size: 15, weight: .medium))
}
}
.buttonStyle(.plain)
.accessibilityLabel("Add attachment")
}
// MARK: - Dictation
private func micButton(_ dictation: DictationController) -> some View {
let recording: Bool = {
if case .recording = dictation.state { return true }
return false
}()
let busy = dictation.state == .starting || dictation.state == .transcribing
return Button {
toggleDictation(dictation)
} label: {
actionCircle(
background: recording
? AnyShapeStyle(.red.opacity(0.15))
: AnyShapeStyle(.fill.tertiary),
foreground: recording ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary)
) {
if busy {
ProgressView()
.controlSize(.small)
} else {
Image(systemName: recording ? "stop.fill" : "mic.fill")
.font(.system(size: 15))
}
}
}
.buttonStyle(.plain)
.disabled(busy)
.accessibilityLabel(recording ? String(localized: "Stop dictation") : String(localized: "Dictate"))
}
private func toggleDictation(_ dictation: DictationController) {
// Stopping never needs the permission; starting checks + requests it.
guard dictation.state == .idle else {
dictation.toggle()
return
}
switch AVAudioApplication.shared.recordPermission {
case .granted:
dictation.toggle()
case .denied:
interactor.postNotice(String(localized: "Microphone access is disabled — allow it in Settings to dictate"))
case .undetermined:
Task {
let granted = await AVAudioApplication.requestRecordPermission()
if granted {
dictation.toggle()
} else {
interactor.postNotice(String(localized: "Microphone permission is needed for dictation"))
}
}
@unknown default:
dictation.toggle()
}
}
// MARK: - Buttons
@ViewBuilder
private func primaryActionButton(_ composer: ComposerState, attachments: [ComposerAttachmentUI]) -> some View {
let hasText = !composer.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let hasDraft = hasText || !attachments.isEmpty
// Attachments gate the send like the web: every chip must settle
// Ready (uploading waits, failed must be retried or removed); a ready
// tray allows an attachments-only send (wire: text or attachments).
let attachmentsBusy = attachments.contains { $0.status != .ready }
let attachmentsReady = !attachments.isEmpty && !attachmentsBusy
let canSubmit = (hasText || attachmentsReady) && !attachmentsBusy && !composer.isSending
let action: ComposerPrimaryAction = if composer.isSending {
.sending
} else if composer.canSteer && !hasDraft {
.stop
} else {
.send(enabled: canSubmit)
}
switch action {
case .sending:
Button {} label: {
actionCircle(
background: AnyShapeStyle(.fill.tertiary),
foreground: AnyShapeStyle(.secondary)
) {
ProgressView()
.controlSize(.small)
}
}
.buttonStyle(.plain)
.disabled(true)
.accessibilityLabel("Send")
case .stop:
Button {
interactor.abortSession()
} label: {
actionCircle(
background: AnyShapeStyle(.red),
foreground: AnyShapeStyle(.white)
) {
Image(systemName: "stop.fill")
.font(.system(size: 15))
}
}
.buttonStyle(.plain)
.accessibilityLabel("Stop the current turn")
case .send(let enabled):
let label = sendLabel(enabled: enabled)
if composer.canSteer && enabled {
// Tap sends (queue); long-press opens the deliberate steer intent.
Menu {
Button("Send & steer into current turn") {
interactor.sendMessage(steer: true)
}
} label: {
label
} primaryAction: {
interactor.sendMessage()
}
.accessibilityLabel("Send — long-press to steer")
} else {
Button {
interactor.sendMessage()
} label: {
label
}
.buttonStyle(.plain)
.disabled(!enabled)
.accessibilityLabel("Send")
}
}
}
private func sendLabel(enabled: Bool) -> some View {
actionCircle(
background: enabled ? AnyShapeStyle(.tint) : AnyShapeStyle(.fill.tertiary),
foreground: enabled ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary)
) {
Image(systemName: "arrow.up")
.font(.system(size: 15, weight: .semibold))
}
}
// Icon-only controls keep their 38pt visuals / 44pt hit areas. Their
// symbols use fixed sizes; editable text remains fully Dynamic Type.
private func actionCircle<Content: View>(
background: AnyShapeStyle,
foreground: AnyShapeStyle,
@ViewBuilder content: () -> Content
) -> some View {
content()
.frame(width: 38, height: 38)
.background(background, in: Circle())
.foregroundStyle(foreground)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
}
private enum ComposerPrimaryAction {
case sending
case stop
case send(enabled: Bool)
}
// MARK: - Attachment chip
/// One tray chip: 36 pt thumb (image preview / MIME glyph, spinner while
/// uploading), filename + status line, ✕ to remove. A failed chip tints red
/// and taps to retry.
private struct ComposerAttachmentChipView: View {
let attachment: ComposerAttachmentUI
let onRetry: () -> Void
let onRemove: () -> Void
private var failed: Bool { attachment.status == .failed }
var body: some View {
HStack(spacing: 8) {
thumb
VStack(alignment: .leading, spacing: 0) {
Text(attachment.filename)
.font(.caption.weight(.medium))
.lineLimit(1)
Text(statusLine)
.font(.caption2)
.foregroundStyle(failed ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary))
.lineLimit(1)
}
.frame(maxWidth: 132, alignment: .leading)
Button {
onRemove()
} label: {
Image(systemName: "xmark")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.padding(6)
}
.buttonStyle(.plain)
.accessibilityLabel("Remove \(attachment.filename)")
}
.padding(6)
.background(
failed ? AnyShapeStyle(.red.opacity(0.12)) : AnyShapeStyle(.fill.tertiary),
in: RoundedRectangle(cornerRadius: 10, style: .continuous)
)
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.onTapGesture {
if failed { onRetry() }
}
}
private var statusLine: String {
switch attachment.status {
case .uploading:
return String(localized: "Uploading…")
case .failed:
return String(localized: "Failed — tap to retry")
case .ready:
return formatChipSize(attachment.sizeBytes)
}
}
private var thumb: some View {
ZStack {
// Preparer-made ≤ 512 px JPEGs — cheap enough to decode inline.
if let previewBytes = attachment.previewBytes,
let image = UIImage(data: previewBytes) {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: 36, height: 36)
.clipped()
.opacity(attachment.status == .uploading ? 0.4 : 1)
} else {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(.fill.secondary)
.frame(width: 36, height: 36)
Image(systemName: attachment.mimeType.hasPrefix("image/") ? "photo" : "doc")
.font(.footnote)
.foregroundStyle(.secondary)
}
if attachment.status == .uploading {
ProgressView()
.controlSize(.small)
}
}
.frame(width: 36, height: 36)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
}
/// `12.3 MB` / `456 KB` / `789 B` chip size label (Android `formatChipSize`).
func formatChipSize(_ bytes: Int) -> String {
if bytes >= 1024 * 1024 {
return String(format: "%.1f MB", Double(bytes) / (1024.0 * 1024.0))
}
if bytes >= 1024 {
return String(format: "%.0f KB", Double(bytes) / 1024.0)
}
return "\(bytes) B"
}
// MARK: - Recording chip
/// Elapsed-time recording chip with a cancel affordance (discards the take).
private struct RecordingChipView: View {
let startedAtMs: Int
let onCancel: () -> Void
var body: some View {
TimelineView(.periodic(from: .now, by: 0.25)) { context in
HStack(spacing: 8) {
Text("● Recording… \(formatElapsed(elapsedSeconds(at: context.date)))")
.font(.caption.weight(.medium))
Spacer(minLength: 8)
Button("Cancel") {
onCancel()
}
.font(.caption.weight(.semibold))
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.red.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.foregroundStyle(.red)
}
}
private func elapsedSeconds(at date: Date) -> Int {
max(0, (Int(date.timeIntervalSince1970 * 1000) - startedAtMs) / 1000)
}
}
/// `m:ss` elapsed-time label for the recording chip.
func formatElapsed(_ totalSeconds: Int) -> String {
String(format: "%d:%02d", totalSeconds / 60, totalSeconds % 60)
}
// MARK: - Video import
/// Photo-picker videos land as a temp file (`FileRepresentation`) so the
/// preparer's capped read — not a full in-memory `Data` — bounds them.
private struct PickedMovie: Transferable {
let url: URL
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(contentType: .movie) { movie in
SentTransferredFile(movie.url)
} importing: { received in
let ext = received.file.pathExtension.isEmpty ? "mov" : received.file.pathExtension
let destination = FileManager.default.temporaryDirectory
.appendingPathComponent("video-\(UUID().uuidString.prefix(8)).\(ext)")
try FileManager.default.copyItem(at: received.file, to: destination)
return PickedMovie(url: destination)
}
}
}