341 lines
12 KiB
Swift
341 lines
12 KiB
Swift
//
|
|
// ChatView.swift
|
|
// SwiftChat
|
|
//
|
|
// Created on 03/25/26.
|
|
// Copyright © 2026 Sacha Servan-Schreiber. All rights reserved.
|
|
//
|
|
|
|
|
|
import SwiftUI
|
|
|
|
|
|
// MARK: - ChatContainer
|
|
|
|
/// The primary SwiftUI container that holds the main chat interface and sidebar navigation.
|
|
struct ChatContainer: View {
|
|
@Environment(\.colorScheme) var colorScheme
|
|
@EnvironmentObject private var viewModel: SwiftChat.ChatViewModel
|
|
@StateObject private var settings = SettingsManager.shared
|
|
|
|
@State private var isSidebarOpen = UIDevice.current.userInterfaceIdiom == .pad
|
|
@State private var messageText = ""
|
|
@State private var dragOffset: CGFloat = 0
|
|
|
|
// Sidebar constants
|
|
private let sidebarWidth: CGFloat = 300
|
|
|
|
private var toolbarContentColor: Color {
|
|
colorScheme == .dark ? Color.white : Color.black
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
mainContent
|
|
.background(Color.chatBackground(isDarkMode: colorScheme == .dark))
|
|
}
|
|
.navigationViewStyle(.stack)
|
|
.environmentObject(viewModel)
|
|
.onAppear {
|
|
setupNavigationBarAppearance()
|
|
isSidebarOpen = false
|
|
dragOffset = 0
|
|
}
|
|
.onChange(of: colorScheme) { _, _ in
|
|
setupNavigationBarAppearance()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in
|
|
withAnimation(.easeOut(duration: 0.2)) {
|
|
dragOffset = 0
|
|
}
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
|
withAnimation(.easeOut(duration: 0.2)) {
|
|
isSidebarOpen = false
|
|
dragOffset = 0
|
|
}
|
|
}
|
|
.fullScreenCover(isPresented: $viewModel.showImageViewer) {
|
|
ImageViewerOverlay(
|
|
images: viewModel.imageViewerImages,
|
|
initialIndex: viewModel.imageViewerIndex,
|
|
onDismiss: { viewModel.showImageViewer = false }
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Configure navigation bar appearance
|
|
private func setupNavigationBarAppearance() {
|
|
if #available(iOS 26, *) {
|
|
let appearance = UINavigationBarAppearance()
|
|
appearance.configureWithTransparentBackground()
|
|
appearance.shadowColor = .clear
|
|
updateAllNavigationBars(with: appearance)
|
|
} else {
|
|
let appearance = UINavigationBarAppearance()
|
|
appearance.configureWithOpaqueBackground()
|
|
appearance.backgroundColor = colorScheme == .dark ? UIColor(Color.backgroundPrimary) : .white
|
|
appearance.shadowColor = .clear
|
|
updateAllNavigationBars(with: appearance)
|
|
}
|
|
}
|
|
|
|
private func updateAllNavigationBars(with appearance: UINavigationBarAppearance) {
|
|
let tintColor: UIColor = colorScheme == .dark ? .white : .black
|
|
UINavigationBar.appearance().standardAppearance = appearance
|
|
UINavigationBar.appearance().compactAppearance = appearance
|
|
UINavigationBar.appearance().scrollEdgeAppearance = appearance
|
|
UINavigationBar.appearance().tintColor = tintColor
|
|
|
|
for scene in UIApplication.shared.connectedScenes {
|
|
if let windowScene = scene as? UIWindowScene {
|
|
for window in windowScene.windows {
|
|
if let navigationBar = window.rootViewController?.navigationController?.navigationBar {
|
|
navigationBar.standardAppearance = appearance
|
|
navigationBar.compactAppearance = appearance
|
|
navigationBar.scrollEdgeAppearance = appearance
|
|
navigationBar.tintColor = tintColor
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The main content layout including chat area and sidebar
|
|
private var mainContent: some View {
|
|
Group {
|
|
if UIDevice.current.userInterfaceIdiom == .pad {
|
|
GeometryReader { geometry in
|
|
HStack(spacing: 0) {
|
|
if isSidebarOpen {
|
|
ChatSidebar(isOpen: $isSidebarOpen, viewModel: viewModel)
|
|
.frame(width: sidebarWidth)
|
|
.transition(.move(edge: .leading))
|
|
}
|
|
chatArea
|
|
.frame(width: isSidebarOpen ? geometry.size.width - sidebarWidth : geometry.size.width)
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: 0.3), value: isSidebarOpen)
|
|
} else {
|
|
ZStack {
|
|
chatArea
|
|
sidebarLayer
|
|
}
|
|
}
|
|
}
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.applyTransparentToolbarIfAvailable()
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button(action: toggleSidebar) {
|
|
MenuToXButton(isX: isSidebarOpen)
|
|
.frame(width: 24, height: 24)
|
|
.foregroundColor(toolbarContentColor)
|
|
}
|
|
}
|
|
ToolbarItem(placement: .principal) {
|
|
Text("SwiftChat")
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundColor(toolbarContentColor)
|
|
.opacity(isSidebarOpen ? 1 : 0)
|
|
.animation(.easeInOut(duration: 0.2), value: isSidebarOpen)
|
|
}
|
|
if !(viewModel.currentChat?.isBlankChat ?? true) {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button(action: createNewChat) {
|
|
Image(systemName: "plus")
|
|
.font(.system(size: 18, weight: .medium))
|
|
.foregroundColor(toolbarContentColor)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.gesture(
|
|
UIDevice.current.userInterfaceIdiom == .phone ?
|
|
DragGesture()
|
|
.onChanged { gesture in
|
|
if isSidebarOpen {
|
|
dragOffset = max(-sidebarWidth, min(0, gesture.translation.width))
|
|
} else {
|
|
dragOffset = max(0, min(sidebarWidth, gesture.translation.width))
|
|
}
|
|
}
|
|
.onEnded { gesture in
|
|
let threshold: CGFloat = 100
|
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) {
|
|
if isSidebarOpen {
|
|
if gesture.translation.width < -threshold {
|
|
isSidebarOpen = false
|
|
}
|
|
} else {
|
|
if gesture.translation.width > threshold {
|
|
isSidebarOpen = true
|
|
dismissKeyboard()
|
|
}
|
|
}
|
|
dragOffset = 0
|
|
}
|
|
} : nil
|
|
)
|
|
.onChange(of: isSidebarOpen) { _, isOpen in
|
|
if !isOpen { dragOffset = 0 }
|
|
}
|
|
}
|
|
|
|
private var chatArea: some View {
|
|
ChatListView(
|
|
isDarkMode: colorScheme == .dark,
|
|
isLoading: viewModel.isLoading,
|
|
viewModel: viewModel,
|
|
messageText: $messageText
|
|
)
|
|
.background(Color.chatBackground(isDarkMode: colorScheme == .dark))
|
|
.ignoresSafeArea(edges: .top)
|
|
}
|
|
|
|
private var sidebarLayer: some View {
|
|
ZStack {
|
|
Color.black
|
|
.opacity({
|
|
let base = 0.4
|
|
let fraction = (dragOffset / sidebarWidth * 0.4)
|
|
return isSidebarOpen ? (base + fraction) : max(0, fraction)
|
|
}())
|
|
.ignoresSafeArea()
|
|
.allowsHitTesting(isSidebarOpen || abs(dragOffset) > 0.1)
|
|
.onTapGesture {
|
|
withAnimation { isSidebarOpen = false }
|
|
}
|
|
|
|
HStack(spacing: 0) {
|
|
ChatSidebar(isOpen: $isSidebarOpen, viewModel: viewModel)
|
|
.frame(width: sidebarWidth)
|
|
.offset(x: isSidebarOpen ?
|
|
(0 + dragOffset) :
|
|
(-(sidebarWidth + 1) + dragOffset))
|
|
Spacer()
|
|
}
|
|
}
|
|
.animation(.easeInOut, value: isSidebarOpen)
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func toggleSidebar() {
|
|
withAnimation {
|
|
isSidebarOpen.toggle()
|
|
if isSidebarOpen { dismissKeyboard() }
|
|
}
|
|
}
|
|
|
|
private func dismissKeyboard() {
|
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
|
}
|
|
|
|
private func createNewChat() {
|
|
if !viewModel.messages.isEmpty {
|
|
let language = settings.selectedLanguage == "System" ? nil : settings.selectedLanguage
|
|
viewModel.createNewChat(language: language)
|
|
messageText = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - WelcomeView
|
|
|
|
struct WelcomeView: View {
|
|
let isDarkMode: Bool
|
|
|
|
var body: some View {
|
|
VStack(spacing: 24) {
|
|
VStack(spacing: 16) {
|
|
Text("Start a conversation")
|
|
.font(.title)
|
|
.fontWeight(.semibold)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.padding(.horizontal, 32)
|
|
}
|
|
.padding(.top, 24)
|
|
.padding(.bottom, 4)
|
|
}
|
|
}
|
|
|
|
// MARK: - Helper Views
|
|
|
|
/// Animated button that transforms between a menu icon and an X
|
|
struct MenuToXButton: View {
|
|
let isX: Bool
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Rectangle()
|
|
.frame(width: 18, height: 2)
|
|
.rotationEffect(.degrees(isX ? 45 : 0))
|
|
.offset(y: isX ? 0 : -6)
|
|
Rectangle()
|
|
.frame(width: 18, height: 2)
|
|
.opacity(isX ? 0 : 1)
|
|
Rectangle()
|
|
.frame(width: 18, height: 2)
|
|
.rotationEffect(.degrees(isX ? -45 : 0))
|
|
.offset(y: isX ? 0 : 6)
|
|
}
|
|
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isX)
|
|
}
|
|
}
|
|
|
|
/// A shape for custom corner rounding
|
|
struct RoundedCorner: Shape {
|
|
var radius: CGFloat = .infinity
|
|
var corners: UIRectCorner = .allCorners
|
|
|
|
func path(in rect: CGRect) -> Path {
|
|
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
|
|
return Path(path.cgPath)
|
|
}
|
|
}
|
|
|
|
/// Extension for applying rounded corners to views
|
|
extension View {
|
|
func corners(_ corners: UIRectCorner) -> some View {
|
|
clipShape(RoundedCorner(radius: 15, corners: corners))
|
|
}
|
|
|
|
@ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
|
|
if condition {
|
|
transform(self)
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
func applyTransparentToolbarIfAvailable() -> some View {
|
|
if #available(iOS 26, *) {
|
|
self.toolbarBackground(.hidden, for: .navigationBar)
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper extension to convert UIView.AnimationCurve to SwiftUI Animation
|
|
extension Animation {
|
|
init(curve: UIView.AnimationCurve, duration: Double) {
|
|
switch curve {
|
|
case .easeInOut:
|
|
self = .easeInOut(duration: duration)
|
|
case .easeIn:
|
|
self = .easeIn(duration: duration)
|
|
case .easeOut:
|
|
self = .easeOut(duration: duration)
|
|
case .linear:
|
|
self = .linear(duration: duration)
|
|
@unknown default:
|
|
self = .easeInOut(duration: duration)
|
|
}
|
|
}
|
|
}
|