mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user