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