Files
SwiftChat/SwiftChat/Views/MessageInputView.swift
T
2026-03-25 14:28:54 -07:00

649 lines
26 KiB
Swift

//
// MessageInputView.swift
// SwiftChat
//
// Created on 03/25/26.
// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved.
//
import SwiftUI
import UIKit
import PhotosUI
/// Input area for typing messages, including attachments and send button
struct MessageInputView: View {
// MARK: - Constants
fileprivate enum Layout {
static let defaultHeight: CGFloat = 72
static let minimumHeight: CGFloat = 72
static let maximumHeight: CGFloat = 180
}
@Binding var messageText: String
@ObservedObject var viewModel: SwiftChat.ChatViewModel
@Environment(\.colorScheme) var colorScheme
@State private var textHeight: CGFloat = Layout.defaultHeight
@ObservedObject private var settings = SettingsManager.shared
@StateObject private var audioService = AudioRecordingService.shared
var isKeyboardVisible: Bool = false
private var isDarkMode: Bool { colorScheme == .dark }
// Attachment picker state
@State private var showAddSheet = false
@State private var showDocumentPicker = false
@State private var showPhotoPicker = false
@State private var showCamera = false
@State private var selectedPhotoItems: [PhotosPickerItem] = []
@State private var pendingPickerAction: PickerAction?
private enum PickerAction {
case camera, photos, files
}
private var showAttachmentError: Binding<Bool> {
Binding(
get: { viewModel.attachmentError != nil },
set: { if !$0 { viewModel.attachmentError = nil } }
)
}
@ViewBuilder
var body: some View {
inputContent
.alert("Attachment Error", isPresented: showAttachmentError) {
Button("OK", role: .cancel) {}
} message: {
Text(viewModel.attachmentError ?? "An error occurred")
}
.sheet(isPresented: $showDocumentPicker) {
DocumentPickerView { url, fileName in
viewModel.addDocumentAttachment(url: url, fileName: fileName)
}
}
.sheet(isPresented: $showPhotoPicker, onDismiss: processSelectedPhotos) {
NavigationStack {
PhotosPicker(selection: $selectedPhotoItems, matching: .images) {
Text("Select Photos")
}
.photosPickerStyle(.inline)
.navigationTitle("Select Photos")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { showPhotoPicker = false }
}
}
}
.presentationDetents([.medium, .large])
}
.fullScreenCover(isPresented: $showCamera) {
CameraPickerView { image in
if let data = image.jpegData(compressionQuality: CGFloat(Constants.Attachments.imageCompressionQuality)) {
viewModel.addImageAttachment(data: data, fileName: "Camera Photo.jpg")
}
}
.ignoresSafeArea()
}
.sheet(isPresented: $showAddSheet, onDismiss: {
guard let action = pendingPickerAction else { return }
pendingPickerAction = nil
switch action {
case .camera: showCamera = true
case .photos: showPhotoPicker = true
case .files: showDocumentPicker = true
}
}) {
AddToSheetView(
viewModel: viewModel,
isDarkMode: isDarkMode,
onCamera: {
pendingPickerAction = .camera
showAddSheet = false
},
onPhotos: {
pendingPickerAction = .photos
showAddSheet = false
},
onFiles: {
pendingPickerAction = .files
showAddSheet = false
}
)
.presentationDetents([.height(340)])
.presentationBackground(isDarkMode ? Color(hex: "161616") : Color(UIColor.systemGroupedBackground))
}
}
@ViewBuilder
private var inputContent: some View {
if #available(iOS 26, *) {
VStack(spacing: 4) {
VStack(spacing: 0) {
if !viewModel.pendingAttachments.isEmpty {
AttachmentPreviewBar(
attachments: viewModel.pendingAttachments,
thumbnails: viewModel.pendingImageThumbnails,
onRemove: { id in viewModel.removePendingAttachment(id: id) }
)
.padding(.horizontal, 12)
.padding(.top, 8)
}
CustomTextEditor(text: $messageText,
textHeight: $textHeight,
placeholderText: viewModel.currentChat?.messages.isEmpty ?? true ? "What's on your mind?" : "Message",
shouldFocusInput: viewModel.shouldFocusInput,
isLoading: viewModel.isLoading,
onFocusHandled: { viewModel.shouldFocusInput = false },
onSendMessage: { text in viewModel.sendMessage(text: text) })
.frame(height: textHeight)
.padding(.horizontal)
HStack {
attachButton
webSearchButton
Spacer()
micButton
Button(action: sendOrCancelMessage) {
Image(systemName: viewModel.isLoading ? "stop.fill" : "arrow.up")
.font(.system(size: 16, weight: .semibold))
.frame(width: 24, height: 24)
.foregroundColor(isDarkMode ? Color.sendButtonForegroundDark : Color.sendButtonForegroundLight)
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.circle)
.glassEffect(.regular.interactive(), in: .circle)
.clipShape(.circle)
.tint(isDarkMode ? Color.sendButtonBackgroundDark : Color.sendButtonBackgroundLight)
.padding(.trailing, 8)
}
.padding(.vertical, 8)
}
.glassEffect(.regular.interactive(), in: RoundedRectangle(cornerRadius: 26))
}
.padding(.horizontal, 12)
.padding(.bottom, isKeyboardVisible ? 12 : 0)
} else {
VStack(spacing: 4) {
VStack(spacing: 0) {
if !viewModel.pendingAttachments.isEmpty {
AttachmentPreviewBar(
attachments: viewModel.pendingAttachments,
thumbnails: viewModel.pendingImageThumbnails,
onRemove: { id in viewModel.removePendingAttachment(id: id) }
)
.padding(.horizontal, 12)
.padding(.top, 8)
}
CustomTextEditor(text: $messageText,
textHeight: $textHeight,
placeholderText: viewModel.currentChat?.messages.isEmpty ?? true ? "What's on your mind?" : "Message",
shouldFocusInput: viewModel.shouldFocusInput,
isLoading: viewModel.isLoading,
onFocusHandled: { viewModel.shouldFocusInput = false },
onSendMessage: { text in viewModel.sendMessage(text: text) })
.frame(height: textHeight)
.padding(.horizontal)
HStack {
attachButton
webSearchButton
Spacer()
micButton
Button(action: sendOrCancelMessage) {
ZStack {
Circle()
.fill(isDarkMode ? Color.sendButtonBackgroundDark : Color.sendButtonBackgroundLight)
.frame(width: 32, height: 32)
Image(systemName: viewModel.isLoading ? "stop.fill" : "arrow.up")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(isDarkMode ? Color.sendButtonForegroundDark : Color.sendButtonForegroundLight)
}
}
.padding(.trailing, 8)
}
.padding(.vertical, 8)
}
.background {
RoundedRectangle(cornerRadius: 26)
.fill(.thickMaterial)
}
}
.padding(.horizontal, 12)
.padding(.bottom, isKeyboardVisible ? 12 : 0)
}
}
@ViewBuilder
private var attachButton: some View {
Button {
showAddSheet = true
} label: {
Image(systemName: "plus")
.font(.system(size: 20))
.foregroundColor(.secondary)
.frame(width: 24, height: 24)
}
.disabled(viewModel.isLoading || viewModel.isProcessingAttachment)
.padding(.leading, 8)
}
@ViewBuilder
private var webSearchButton: some View {
Button(action: {
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
viewModel.isWebSearchEnabled.toggle()
settings.webSearchEnabled = viewModel.isWebSearchEnabled
}
}) {
if viewModel.isWebSearchEnabled {
HStack(spacing: 6) {
Image(systemName: "globe")
.font(.system(size: 14, weight: .semibold))
Text("Web Search")
.font(.system(size: 12, weight: .semibold))
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.secondary.opacity(0.15))
.clipShape(Capsule())
.foregroundColor(.blue)
} else {
Image(systemName: "globe")
.font(.system(size: 20))
.foregroundColor(.secondary)
.frame(width: 24, height: 24)
}
}
.padding(.leading, 8)
}
@State private var isPulsing = false
@ViewBuilder
private var micButton: some View {
Button(action: toggleRecording) {
ZStack {
if audioService.isRecording {
Circle()
.fill(Color.red.opacity(0.2))
.frame(width: 44, height: 44)
.scaleEffect(isPulsing ? 1.1 : 0.9)
.animation(
.easeInOut(duration: 0.8).repeatForever(autoreverses: true),
value: isPulsing
)
}
Group {
if audioService.isTranscribing {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .secondary))
.scaleEffect(0.8)
} else {
Image(systemName: audioService.isRecording ? "stop.fill" : "mic.fill")
.font(.system(size: 20))
}
}
.frame(width: 32, height: 32)
.foregroundColor(audioService.isRecording ? .red : .secondary)
}
.frame(width: 32, height: 32)
}
.onChange(of: audioService.isRecording) { _, isRecording in
isPulsing = isRecording
}
.disabled(audioService.isTranscribing || viewModel.isLoading)
.padding(.trailing, 4)
}
private func toggleRecording() {
if audioService.isRecording {
guard let fileURL = audioService.stopRecording() else { return }
Task {
do {
let client = AppConfig.shared.makeClient()
let text = try await audioService.transcribe(fileURL: fileURL, client: client)
messageText += (messageText.isEmpty ? "" : " ") + text
} catch {
viewModel.attachmentError = error.localizedDescription
}
}
} else {
Task {
let granted = await audioService.requestPermission()
guard granted else {
viewModel.attachmentError = "Microphone access is required for voice input. Enable it in Settings."
return
}
do {
try audioService.startRecording()
} catch {
viewModel.attachmentError = "Failed to start recording: \(error.localizedDescription)"
}
}
}
}
private func sendOrCancelMessage() {
if viewModel.isLoading {
viewModel.cancelGeneration()
} else if !messageText.isEmpty || !viewModel.pendingAttachments.isEmpty {
viewModel.sendMessage(text: messageText)
messageText = ""
textHeight = Layout.defaultHeight
}
}
private func processSelectedPhotos() {
let items = selectedPhotoItems
selectedPhotoItems = []
for (index, item) in items.enumerated() {
Task {
if let data = try? await item.loadTransferable(type: Data.self) {
let fileName = items.count > 1 ? "Photo \(index + 1).jpg" : "Photo.jpg"
viewModel.addImageAttachment(data: data, fileName: fileName)
}
}
}
}
}
/// Bottom sheet presented from the "+" button with attachment options and model selector
struct AddToSheetView: View {
@ObservedObject var viewModel: SwiftChat.ChatViewModel
let isDarkMode: Bool
let onCamera: () -> Void
let onPhotos: () -> Void
let onFiles: () -> Void
@Environment(\.dismiss) private var dismiss
private var availableModels: [ModelType] {
AppConfig.shared.filteredModelTypes()
}
var body: some View {
NavigationStack {
VStack(spacing: 20) {
// Attachment buttons
HStack(spacing: 12) {
if viewModel.currentModel.isMultimodal {
if UIImagePickerController.isSourceTypeAvailable(.camera) {
attachmentButton(icon: "camera", label: "Camera") { onCamera() }
}
attachmentButton(icon: "photo.on.rectangle", label: "Photos") { onPhotos() }
}
attachmentButton(icon: "doc.badge.arrow.up", label: "Files") { onFiles() }
}
.padding(.horizontal, 20)
Divider()
.padding(.horizontal, 20)
// Model selector
Text("Select a Model")
.font(.system(size: 17, weight: .semibold))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, 20)
.padding(.bottom, -12)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(availableModels) { model in
ModelCard(
model: model,
isSelected: viewModel.currentModel.id == model.id,
isDarkMode: isDarkMode
) {
viewModel.changeModel(to: model)
}
}
}
.padding(.horizontal, 20)
}
}
.padding(.top, 8)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background((isDarkMode ? Color(hex: "161616") : Color(UIColor.systemGroupedBackground)).ignoresSafeArea())
.navigationTitle("Add to Chat")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 18, weight: .medium))
}
}
}
}
}
@ViewBuilder
private func attachmentButton(icon: String, label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 22))
Text(label)
.font(.system(size: 12, weight: .medium))
}
.foregroundColor(.primary)
.frame(maxWidth: .infinity)
.frame(height: 72)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemGroupedBackground))
)
}
}
}
/// Simple model card for the model selector
struct ModelCard: View {
let model: ModelType
let isSelected: Bool
let isDarkMode: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
VStack(spacing: 8) {
Image(model.iconName)
.resizable()
.scaledToFit()
.frame(width: 36, height: 36)
Text(model.displayName)
.font(.system(size: 13, weight: .medium))
.multilineTextAlignment(.center)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
.foregroundColor(isSelected ? .primary : .secondary)
.frame(width: 120, height: 110)
.background(
ZStack {
if #available(iOS 26, *) {
RoundedRectangle(cornerRadius: 16)
.fill(.thickMaterial)
} else {
RoundedRectangle(cornerRadius: 16)
.fill(Color.chatSurface(isDarkMode: isDarkMode))
}
if isSelected {
RoundedRectangle(cornerRadius: 16)
.fill(Color.accentPrimary.opacity(0.15))
}
if !isSelected {
RoundedRectangle(cornerRadius: 16)
.strokeBorder(Color.gray.opacity(0.2), lineWidth: 1)
}
if isSelected {
VStack {
HStack {
Spacer()
Circle()
.fill(Color.accentPrimary)
.frame(width: 16, height: 16)
.overlay(
Image(systemName: "checkmark")
.font(.system(size: 9, weight: .bold))
.foregroundColor(.white)
)
}
Spacer()
}
.padding(8)
}
}
)
}
.buttonStyle(PlainButtonStyle())
.scaleEffect(isSelected ? 1.02 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isSelected)
}
}
/// Custom UIViewRepresentable for a properly managed text editor
struct CustomTextEditor: UIViewRepresentable {
@Binding var text: String
@Binding var textHeight: CGFloat
var placeholderText: String
var shouldFocusInput: Bool
var isLoading: Bool
var onFocusHandled: () -> Void
var onSendMessage: (String) -> Void
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.delegate = context.coordinator
textView.font = UIFont.preferredFont(forTextStyle: .body)
textView.backgroundColor = .clear
textView.isScrollEnabled = true
textView.isEditable = true
textView.isSelectable = true
textView.alwaysBounceVertical = false
textView.scrollsToTop = false
textView.textContainerInset = UIEdgeInsets(top: 16, left: 2, bottom: 8, right: 5)
textView.textContainer.lineFragmentPadding = 0
textView.tintColor = UIColor.systemBlue
if text.isEmpty {
textView.text = placeholderText
textView.textColor = .lightGray
} else {
textView.text = text
textView.textColor = UIColor { traitCollection in
return traitCollection.userInterfaceStyle == .dark ? .white : .black
}
}
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
context.coordinator.parent = self
let isCurrentlyEditing = context.coordinator.isEditing
if shouldFocusInput && !context.coordinator.hasFocusedFromFlag {
context.coordinator.hasFocusedFromFlag = true
DispatchQueue.main.async {
if !uiView.isFirstResponder { uiView.becomeFirstResponder() }
self.onFocusHandled()
}
} else if !shouldFocusInput {
context.coordinator.hasFocusedFromFlag = false
}
if text.isEmpty && !isCurrentlyEditing && uiView.textColor != .lightGray {
uiView.text = placeholderText
uiView.textColor = .lightGray
} else if text.isEmpty && isCurrentlyEditing {
if uiView.text.isEmpty && uiView.textColor == .lightGray {
uiView.text = ""
uiView.textColor = UIColor { tc in tc.userInterfaceStyle == .dark ? .white : .black }
} else if !uiView.text.isEmpty && uiView.textColor != .lightGray {
self.text = uiView.text
}
} else if !text.isEmpty && uiView.textColor == .lightGray {
uiView.text = text
uiView.textColor = UIColor { tc in tc.userInterfaceStyle == .dark ? .white : .black }
} else if !text.isEmpty && uiView.text != text && uiView.textColor != .lightGray {
uiView.text = text
}
uiView.isEditable = true
let size = uiView.sizeThatFits(CGSize(width: uiView.frame.width, height: CGFloat.greatestFiniteMagnitude))
let newHeight = min(MessageInputView.Layout.maximumHeight, max(MessageInputView.Layout.minimumHeight, size.height))
if textHeight != newHeight {
DispatchQueue.main.async { self.textHeight = newHeight }
}
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
class Coordinator: NSObject, UITextViewDelegate {
var parent: CustomTextEditor
var isEditing = false
var hasFocusedFromFlag = false
init(_ parent: CustomTextEditor) { self.parent = parent }
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
if text == "\n" {
let isMac = ProcessInfo.processInfo.isiOSAppOnMac
if isMac {
let currentText = textView.text ?? ""
let trimmedText = currentText.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedText.isEmpty && !parent.isLoading {
parent.onSendMessage(trimmedText)
textView.text = ""
parent.text = ""
parent.textHeight = MessageInputView.Layout.defaultHeight
textView.text = parent.placeholderText
textView.textColor = .lightGray
textView.resignFirstResponder()
}
return false
}
}
let currentText = textView.text as NSString
let newText = currentText.replacingCharacters(in: range, with: text)
if newText.isEmpty && isEditing { return true }
return true
}
func textViewDidChange(_ textView: UITextView) {
if textView.textColor != .lightGray {
parent.text = textView.text
let size = textView.sizeThatFits(CGSize(width: textView.frame.width, height: CGFloat.greatestFiniteMagnitude))
let newHeight = min(MessageInputView.Layout.maximumHeight, max(MessageInputView.Layout.minimumHeight, size.height))
if parent.textHeight != newHeight { parent.textHeight = newHeight }
}
}
func textViewDidBeginEditing(_ textView: UITextView) {
isEditing = true
if textView.textColor == .lightGray {
textView.text = ""
textView.textColor = UIColor { tc in tc.userInterfaceStyle == .dark ? .white : .black }
}
}
func textViewDidEndEditing(_ textView: UITextView) {
isEditing = false
if textView.text.isEmpty {
textView.text = parent.placeholderText
textView.textColor = .lightGray
}
}
}
}