Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawChatUI/CleanChatComposerControls.swift
Colin Johnson 1ca6b6ff67 feat(ios): unify chat and voice experience (#107879)
* feat(ios): unify chat and voice experience

* refactor(ios): finish unified chat ownership

* fix(ios): unify chat and voice capture ownership

* fix(ios): serialize Talk permission hydration

* fix(ios): clear voice CI regressions

* fix(ios): clear remaining unified chat CI failures

* chore(i18n): sync native source inventory

* refactor(ios): defer provider-only realtime voice

* fix(ios): clean unified chat voice PR

* fix(ios): localize unified voice controls

* fix(ios): serialize composer audio controls

* fix(ios): isolate dictation preparation lifecycle

* fix(ios): key chat tab icons by appearance

* fix(ios): preserve talk upgrade handshakes

* fix(ios): refresh native i18n inventory

* fix(ios): reconcile shared locale artifacts

* fix(ios): satisfy chat view lint limit

* fix(ios): reconcile shared composer implementation

* test(apple): avoid XCTest actor deinit crash

* fix(ios): satisfy SwiftFormat scope spacing

* fix(ios): remove duplicate merged helper declarations
2026-07-18 14:30:10 -04:00

338 lines
12 KiB
Swift

import SwiftUI
#if !os(macOS)
import PhotosUI
#if canImport(UIKit)
import UIKit
#endif
#endif
struct CleanChatComposerSurface: ViewModifier {
let cornerRadius: CGFloat
func body(content: Content) -> some View {
#if os(macOS)
content
.background(
RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous)
.fill(OpenClawChatTheme.composerField))
.overlay(
RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous)
.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1))
#else
if #available(iOS 26.0, *) {
content
.glassEffect(.regular, in: .rect(cornerRadius: self.cornerRadius))
} else {
content
.background(
.regularMaterial,
in: RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous)
.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1))
}
#endif
}
}
enum CleanChatComposerMetrics {
static let controlHeight: CGFloat = 44
}
struct CompactChatAttachmentLabel: View {
var body: some View {
Image(systemName: "plus")
.font(OpenClawChatTypography.display(size: 15, weight: .semibold, relativeTo: .subheadline))
.foregroundStyle(.secondary)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
}
struct OpenClawChatAttachmentsStrip: View {
let attachments: [OpenClawPendingAttachment]
let onRemove: @MainActor (OpenClawPendingAttachment.ID) -> Void
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(self.attachments, id: \OpenClawPendingAttachment.id) { attachment in
HStack(spacing: 6) {
if let image = attachment.preview {
OpenClawPlatformImageFactory.image(image)
.resizable()
.scaledToFill()
.frame(width: 22, height: 22)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
} else if attachment.mimeType.hasPrefix("audio/") {
Image(systemName: "waveform")
Text("Voice note")
.font(OpenClawChatTypography.caption)
if let duration = attachment.durationSeconds {
Text(openClawVoiceNoteDurationLabel(duration))
.font(OpenClawChatTypography.caption)
.foregroundStyle(.secondary)
}
} else {
Image(systemName: "photo")
Text(attachment.fileName)
.font(OpenClawChatTypography.caption)
.lineLimit(1)
}
if attachment.preview != nil {
Text(attachment.fileName)
.font(OpenClawChatTypography.caption)
.lineLimit(1)
}
Button {
self.onRemove(attachment.id)
} label: {
Image(systemName: "xmark.circle.fill")
}
.buttonStyle(.plain)
}
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(OpenClawChatTheme.accent.opacity(0.08))
.clipShape(Capsule())
}
}
}
}
}
#if !os(macOS)
struct OpenClawChatAttachmentMenu: View {
@Binding var showsPhotoPicker: Bool
@Binding var showsFileImporter: Bool
@Binding var showsCameraPicker: Bool
let isAttachmentInputEnabled: Bool
var body: some View {
Menu {
Button {
self.showsPhotoPicker = true
} label: {
Label {
Text("Photo Library")
.font(OpenClawChatTypography.body)
} icon: {
Image(systemName: "photo.on.rectangle")
}
}
#if canImport(UIKit)
Button {
self.showsCameraPicker = true
} label: {
Label {
Text("Camera")
.font(OpenClawChatTypography.body)
} icon: {
Image(systemName: "camera")
}
}
.disabled(!UIImagePickerController.isSourceTypeAvailable(.camera))
#endif
Button {
self.showsFileImporter = true
} label: {
Label {
Text("Choose Image File")
.font(OpenClawChatTypography.body)
} icon: {
Image(systemName: "folder")
}
}
} label: {
CompactChatAttachmentLabel()
}
.help("Add attachment")
.accessibilityLabel("Add attachment")
.accessibilityIdentifier("chat-attachment-picker")
.buttonStyle(.plain)
.disabled(!self.isAttachmentInputEnabled)
}
}
#if canImport(UIKit)
struct OpenClawChatCameraPicker: UIViewControllerRepresentable {
let onImage: @MainActor (UIImage) -> Void
@Environment(\.dismiss) private var dismiss
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
func makeUIViewController(context: Context) -> UIImagePickerController {
let controller = UIImagePickerController()
controller.sourceType = .camera
controller.delegate = context.coordinator
return controller
}
func updateUIViewController(_: UIImagePickerController, context _: Context) {}
final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
let parent: OpenClawChatCameraPicker
init(parent: OpenClawChatCameraPicker) {
self.parent = parent
}
func imagePickerController(
_: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any])
{
if let image = info[.originalImage] as? UIImage {
self.parent.onImage(image)
}
self.parent.dismiss()
}
func imagePickerControllerDidCancel(_: UIImagePickerController) {
self.parent.dismiss()
}
}
}
#endif
#endif
struct OpenClawChatMicButton: View {
let dictationControl: OpenClawChatDictationControl?
let voiceNoteControl: OpenClawChatVoiceNoteControl?
let isDictationPending: Bool
let isRealtimeTalkActive: Bool
let isComposerEnabled: Bool
let isAttachmentInputEnabled: Bool
let onStartDictation: @MainActor () -> Void
var body: some View {
if let voiceNoteControl {
if self.isDictationActionEnabled, let dictationControl {
Menu {
self.voiceNoteAction(voiceNoteControl)
} label: {
self.label
} primaryAction: {
self.performDictationAction()
}
.menuIndicator(.hidden)
.buttonStyle(.plain)
.modifier(UnifiedChatMicMetadata(control: dictationControl))
} else {
Menu {
self.voiceNoteAction(voiceNoteControl)
} label: {
self.label
}
.menuIndicator(.hidden)
.buttonStyle(.plain)
.disabled(!self.isVoiceNoteRecordingEnabled(voiceNoteControl))
.accessibilityLabel("Record Voice Note")
.accessibilityIdentifier("chat-dictation-control")
.help("Record Voice Note")
}
} else if let dictationControl {
Button(action: self.performDictationAction) {
self.label
}
.buttonStyle(.plain)
.disabled(!self.isDictationActionEnabled)
.modifier(UnifiedChatMicMetadata(control: dictationControl))
}
}
private var label: some View {
Image(systemName: self.dictationControl?.isActive == true ? "stop.fill" : "mic")
.font(OpenClawChatTypography.display(size: 17, weight: .medium, relativeTo: .body))
.foregroundStyle(self.dictationControl?.isActive == true ? OpenClawChatTheme.accent : .secondary)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
private func performDictationAction() {
guard let dictationControl else { return }
if dictationControl.isActive {
dictationControl.finish()
} else {
self.onStartDictation()
}
}
private var isDictationActionEnabled: Bool {
Self.dictationActionEnabled(
isComposerEnabled: self.isComposerEnabled,
isAvailable: self.dictationControl?.isAvailable == true,
isActive: self.dictationControl?.isActive == true,
isTalkActive: self.isRealtimeTalkActive || self.voiceNoteControl?.isTalkActive == true,
isVoiceNoteCaptureActive: self.voiceNoteControl?.recorder.isRecording == true ||
self.voiceNoteControl?.recorder.isRequestingPermission == true)
}
private func voiceNoteAction(_ voiceNoteControl: OpenClawChatVoiceNoteControl) -> some View {
Button {
Task { await voiceNoteControl.recorder.start() }
} label: {
Label("Record Voice Note", systemImage: "waveform")
}
.disabled(!self.isVoiceNoteRecordingEnabled(voiceNoteControl))
}
private func isVoiceNoteRecordingEnabled(_ voiceNoteControl: OpenClawChatVoiceNoteControl) -> Bool {
Self.voiceNoteRecordingEnabled(
isComposerEnabled: self.isComposerEnabled,
isAttachmentInputEnabled: self.isAttachmentInputEnabled,
isDictationActive: self.dictationControl?.isActive == true,
isDictationPending: self.isDictationPending,
isTalkActive: self.isRealtimeTalkActive || voiceNoteControl.isTalkActive,
isRecording: voiceNoteControl.recorder.isRecording,
isRequestingPermission: voiceNoteControl.recorder.isRequestingPermission)
}
nonisolated static func dictationActionEnabled(
isComposerEnabled: Bool,
isAvailable: Bool,
isActive: Bool,
isTalkActive: Bool,
isVoiceNoteCaptureActive: Bool) -> Bool
{
isActive || (isComposerEnabled && isAvailable && !isTalkActive && !isVoiceNoteCaptureActive)
}
nonisolated static func voiceNoteRecordingEnabled(
isComposerEnabled: Bool,
isAttachmentInputEnabled: Bool,
isDictationActive: Bool,
isDictationPending: Bool,
isTalkActive: Bool,
isRecording: Bool,
isRequestingPermission: Bool) -> Bool
{
isComposerEnabled
&& isAttachmentInputEnabled
&& !isDictationActive
&& !isDictationPending
&& !isTalkActive
&& !isRecording
&& !isRequestingPermission
}
}
private struct UnifiedChatMicMetadata: ViewModifier {
let control: OpenClawChatDictationControl
func body(content: Content) -> some View {
content
.accessibilityLabel(self.control.isActive ? "Finish dictation" : "Dictate message")
.accessibilityValue(self.control.isActive ? "Listening" : "Not listening")
.accessibilityIdentifier("chat-dictation-control")
.help(self.control.isActive ? "Finish dictation" : "Transcribe speech into the message")
}
}