feat(ios-chat): redesign composer with HIG touch targets and clear disabled state

Send button is now a 44pt circular target with an explicit color swap
(rust accent → background-tertiary) on disable, instead of relying on
SwiftUI's default opacity dim — addresses the "first tap doesn't
register" complaint by making the inactive state visibly different in
both light and dark mode. Paperclip and text field both gain a 44pt
minimum height so the row feels modern and roomy.

The text field swaps `.roundedBorder` for a plain field with a
ScarfRadius.xl rounded fill (ScarfColor.backgroundSecondary) and a
borderStrong stroke. Outer paddings and HStack spacing migrate from
magic numbers to ScarfSpace tokens.

Preserves verbatim: the `.toolbar { ToolbarItemGroup(placement: .keyboard) }`
keyboard-dismiss chevron (issue #51), draft persistence, .submitLabel,
@FocusState, photo-picker wiring, attachment-strip rendering, and every
.disabled() predicate.

Closes #69

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-03 13:14:09 +02:00
parent 982ed7da92
commit ab615f0c28
+53 -11
View File
@@ -448,14 +448,15 @@ struct ChatView: View {
} }
private var composer: some View { private var composer: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: ScarfSpace.s2) {
if !controller.attachments.isEmpty || isEncodingAttachment || attachmentError != nil { if !controller.attachments.isEmpty || isEncodingAttachment || attachmentError != nil {
attachmentStrip attachmentStrip
} }
composerRow composerRow
} }
.padding(.horizontal, 12) .padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, 8) .padding(.top, ScarfSpace.s2)
.padding(.bottom, ScarfSpace.s2)
.background(.regularMaterial) .background(.regularMaterial)
#if canImport(PhotosUI) #if canImport(PhotosUI)
.photosPicker( .photosPicker(
@@ -536,18 +537,23 @@ struct ChatView: View {
} }
private var composerRow: some View { private var composerRow: some View {
HStack(alignment: .bottom, spacing: 8) { HStack(alignment: .bottom, spacing: ScarfSpace.s2) {
if supportsImagePrompts { if supportsImagePrompts {
Button { Button {
showPhotoPicker = true showPhotoPicker = true
} label: { } label: {
Image(systemName: "paperclip") Image(systemName: "paperclip")
.font(.system(size: 22)) .font(.system(size: 20, weight: .regular))
.foregroundStyle(.secondary) .foregroundStyle(
.padding(.bottom, 4) attachDisabled
? ScarfColor.foregroundFaint
: ScarfColor.foregroundMuted
)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(controller.state != .ready || controller.attachments.count >= Self.maxAttachments) .disabled(attachDisabled)
.accessibilityLabel("Attach image") .accessibilityLabel("Attach image")
} }
TextField( TextField(
@@ -555,8 +561,19 @@ struct ChatView: View {
text: $controller.draft, text: $controller.draft,
axis: .vertical axis: .vertical
) )
.textFieldStyle(.roundedBorder) .textFieldStyle(.plain)
.lineLimit(1...5) .lineLimit(1...5)
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, ScarfSpace.s2)
.frame(minHeight: 44)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.xl, style: .continuous)
.fill(ScarfColor.backgroundSecondary)
)
.overlay(
RoundedRectangle(cornerRadius: ScarfRadius.xl, style: .continuous)
.strokeBorder(ScarfColor.borderStrong, lineWidth: 1)
)
.disabled(controller.state != .ready) .disabled(controller.state != .ready)
.submitLabel(.send) .submitLabel(.send)
.focused($composerFocused) .focused($composerFocused)
@@ -592,13 +609,32 @@ struct ChatView: View {
} }
} }
// Big circular send button. Filled with the brand accent when
// ready, swapped to a flat gray when disabled opacity dims
// alone read as "not quite tappable" (issue #69), the explicit
// color swap makes the state unambiguous in both light and
// dark mode.
Button { Button {
Task { await controller.send() } Task { await controller.send() }
} label: { } label: {
Image(systemName: "arrow.up.circle.fill") ZStack {
.font(.system(size: 28)) Circle()
.fill(canSendComposer
? ScarfColor.accent
: ScarfColor.backgroundTertiary)
Image(systemName: "arrow.up")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(canSendComposer
? ScarfColor.onAccent
: ScarfColor.foregroundFaint)
} }
.frame(width: 44, height: 44)
.contentShape(Circle())
.animation(ScarfAnimation.fast, value: canSendComposer)
}
.buttonStyle(.plain)
.disabled(!canSendComposer) .disabled(!canSendComposer)
.accessibilityLabel("Send message")
} }
} }
@@ -610,6 +646,12 @@ struct ChatView: View {
return !controller.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty return !controller.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
} }
/// Mirror of the `.disabled(...)` predicate on the paperclip button.
/// Pulled out so the button's foreground branch reads cleanly.
private var attachDisabled: Bool {
controller.state != .ready || controller.attachments.count >= Self.maxAttachments
}
/// Pull JPEG/PNG bytes out of each PhotosPickerItem and feed them /// Pull JPEG/PNG bytes out of each PhotosPickerItem and feed them
/// through ImageEncoder. Detached so the heavyweight resize + /// through ImageEncoder. Detached so the heavyweight resize +
/// JPEG-encode work doesn't block MainActor; the resulting /// JPEG-encode work doesn't block MainActor; the resulting