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

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)
}
}
}