178 lines
6.2 KiB
Swift
178 lines
6.2 KiB
Swift
import SwiftUI
|
||
|
||
struct ChatView: View {
|
||
@EnvironmentObject var appState: AppState
|
||
@State private var messages: [ChatMessage] = []
|
||
@State private var inputText = ""
|
||
@State private var isLoading = false
|
||
@FocusState private var isInputFocused: Bool
|
||
|
||
var body: some View {
|
||
NavigationView {
|
||
VStack(spacing: 0) {
|
||
// Messages
|
||
ScrollViewReader { proxy in
|
||
ScrollView {
|
||
LazyVStack(spacing: 12) {
|
||
ForEach(messages) { message in
|
||
MessageBubble(message: message)
|
||
.id(message.id)
|
||
}
|
||
|
||
if isLoading {
|
||
HStack {
|
||
TypingIndicator()
|
||
Spacer()
|
||
}
|
||
.padding(.horizontal)
|
||
}
|
||
}
|
||
.padding()
|
||
}
|
||
.onChange(of: messages.count) { _ in
|
||
if let last = messages.last {
|
||
withAnimation {
|
||
proxy.scrollTo(last.id, anchor: .bottom)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Input
|
||
HStack(spacing: 12) {
|
||
TextField("Сообщение...", text: $inputText)
|
||
.padding(12)
|
||
.background(Color("Card"))
|
||
.cornerRadius(20)
|
||
.focused($isInputFocused)
|
||
|
||
Button(action: sendMessage) {
|
||
Image(systemName: "arrow.up")
|
||
.fontWeight(.semibold)
|
||
.foregroundColor(Color("Background"))
|
||
.frame(width: 40, height: 40)
|
||
.background(Color("Gold"))
|
||
.clipShape(Circle())
|
||
}
|
||
.disabled(inputText.trimmingCharacters(in: .whitespaces).isEmpty || isLoading)
|
||
}
|
||
.padding()
|
||
.background(Color("Background"))
|
||
}
|
||
.background(Color("Background").ignoresSafeArea())
|
||
.navigationTitle("Юнона ☀️")
|
||
.onAppear {
|
||
if messages.isEmpty {
|
||
// Welcome message
|
||
messages.append(ChatMessage(
|
||
role: "assistant",
|
||
content: "Привет! Я Юнона ☀️\n\nЯ AI ассистент Montana Protocol. Могу помочь с переводами, рассказать о протоколе или просто поговорить.\n\nО чём хочешь поговорить?"
|
||
))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func sendMessage() {
|
||
let text = inputText.trimmingCharacters(in: .whitespaces)
|
||
guard !text.isEmpty else { return }
|
||
|
||
// Add user message
|
||
messages.append(ChatMessage(role: "user", content: text))
|
||
inputText = ""
|
||
isLoading = true
|
||
|
||
// Send to API
|
||
guard let deviceId = UserDefaults.standard.string(forKey: "deviceId") else {
|
||
messages.append(ChatMessage(role: "assistant", content: "Ошибка: не авторизован"))
|
||
isLoading = false
|
||
return
|
||
}
|
||
|
||
API.shared.sendMessage(deviceId: deviceId, message: text) { result in
|
||
DispatchQueue.main.async {
|
||
isLoading = false
|
||
switch result {
|
||
case .success(let response):
|
||
messages.append(ChatMessage(role: "assistant", content: response))
|
||
case .failure:
|
||
messages.append(ChatMessage(role: "assistant", content: "Ошибка соединения. Попробуй позже."))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Message Bubble
|
||
struct MessageBubble: View {
|
||
let message: ChatMessage
|
||
|
||
var isUser: Bool { message.role == "user" }
|
||
|
||
var body: some View {
|
||
HStack {
|
||
if isUser { Spacer() }
|
||
|
||
Text(message.content)
|
||
.padding(12)
|
||
.background(
|
||
isUser ?
|
||
LinearGradient(colors: [Color("Gold"), Color(hex: "FFA500")], startPoint: .leading, endPoint: .trailing) :
|
||
LinearGradient(colors: [Color("Card"), Color("Card")], startPoint: .leading, endPoint: .trailing)
|
||
)
|
||
.foregroundColor(isUser ? Color("Background") : .white)
|
||
.cornerRadius(18)
|
||
.cornerRadius(isUser ? 4 : 18, corners: isUser ? .bottomRight : .bottomLeft)
|
||
|
||
if !isUser { Spacer() }
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Typing Indicator
|
||
struct TypingIndicator: View {
|
||
@State private var animating = false
|
||
|
||
var body: some View {
|
||
HStack(spacing: 4) {
|
||
ForEach(0..<3) { i in
|
||
Circle()
|
||
.fill(Color.secondary)
|
||
.frame(width: 8, height: 8)
|
||
.scaleEffect(animating ? 1 : 0.5)
|
||
.animation(
|
||
.easeInOut(duration: 0.6)
|
||
.repeatForever()
|
||
.delay(Double(i) * 0.2),
|
||
value: animating
|
||
)
|
||
}
|
||
}
|
||
.padding(12)
|
||
.background(Color("Card"))
|
||
.cornerRadius(18)
|
||
.onAppear { animating = true }
|
||
}
|
||
}
|
||
|
||
// MARK: - Corner Radius Extension
|
||
extension View {
|
||
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
|
||
clipShape(RoundedCorner(radius: radius, corners: corners))
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|