293 lines
9.7 KiB
Swift
293 lines
9.7 KiB
Swift
import SwiftUI
|
|
|
|
struct ChatsView: View {
|
|
@EnvironmentObject var appState: AppState
|
|
@State private var conversations: [Conversation] = []
|
|
@State private var isLoading = false
|
|
@State private var searchText = ""
|
|
|
|
var filteredConversations: [Conversation] {
|
|
if searchText.isEmpty {
|
|
return conversations
|
|
}
|
|
return conversations.filter {
|
|
$0.contactName.localizedCaseInsensitiveContains(searchText) ||
|
|
$0.contactPhone.contains(searchText)
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
ZStack {
|
|
Color("Background").ignoresSafeArea()
|
|
|
|
if isLoading {
|
|
ProgressView()
|
|
.tint(Color("Gold"))
|
|
} else if conversations.isEmpty {
|
|
VStack(spacing: 20) {
|
|
Image(systemName: "bubble.left.and.bubble.right.fill")
|
|
.font(.system(size: 60))
|
|
.foregroundColor(.secondary)
|
|
|
|
Text("Нет сообщений")
|
|
.font(.title2)
|
|
.foregroundColor(.secondary)
|
|
|
|
Text("Напиши первому контакту из списка")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
} else {
|
|
List {
|
|
ForEach(filteredConversations) { conv in
|
|
NavigationLink(destination: ConversationView(
|
|
contactPhone: conv.contactPhone,
|
|
contactName: conv.contactName
|
|
)) {
|
|
ConversationRow(conversation: conv)
|
|
}
|
|
.listRowBackground(Color("Card"))
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.searchable(text: $searchText, prompt: "Поиск")
|
|
}
|
|
}
|
|
.navigationTitle("Чаты")
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button(action: loadConversations) {
|
|
Image(systemName: "arrow.clockwise")
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
loadConversations()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadConversations() {
|
|
guard let deviceId = appState.deviceId else { return }
|
|
isLoading = true
|
|
|
|
API.shared.getConversations(deviceId: deviceId) { result in
|
|
DispatchQueue.main.async {
|
|
isLoading = false
|
|
switch result {
|
|
case .success(let convs):
|
|
conversations = convs.sorted { $0.lastMessageTime > $1.lastMessageTime }
|
|
case .failure:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Conversation Row
|
|
struct ConversationRow: View {
|
|
let conversation: Conversation
|
|
|
|
var body: some View {
|
|
HStack(spacing: 14) {
|
|
// Avatar
|
|
Text(String(conversation.contactName.prefix(1)).uppercased())
|
|
.font(.headline)
|
|
.foregroundColor(Color("Background"))
|
|
.frame(width: 52, height: 52)
|
|
.background(
|
|
LinearGradient(
|
|
colors: [Color("Gold"), Color(hex: "FFA500")],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.clipShape(Circle())
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(conversation.contactName)
|
|
.font(.body)
|
|
.fontWeight(.medium)
|
|
.foregroundColor(.white)
|
|
|
|
Spacer()
|
|
|
|
Text(formatTime(conversation.lastMessageTime))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
HStack {
|
|
Text(conversation.lastMessage)
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(1)
|
|
|
|
Spacer()
|
|
|
|
if conversation.unreadCount > 0 {
|
|
Text("\(conversation.unreadCount)")
|
|
.font(.caption)
|
|
.fontWeight(.bold)
|
|
.foregroundColor(Color("Background"))
|
|
.frame(minWidth: 20, minHeight: 20)
|
|
.background(Color("Gold"))
|
|
.clipShape(Circle())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 6)
|
|
}
|
|
|
|
private func formatTime(_ date: Date) -> String {
|
|
let calendar = Calendar.current
|
|
if calendar.isDateInToday(date) {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "HH:mm"
|
|
return formatter.string(from: date)
|
|
} else if calendar.isDateInYesterday(date) {
|
|
return "Вчера"
|
|
} else {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "dd.MM"
|
|
return formatter.string(from: date)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Conversation View (P2P Chat)
|
|
struct ConversationView: View {
|
|
@EnvironmentObject var appState: AppState
|
|
let contactPhone: String
|
|
let contactName: String
|
|
|
|
@State private var messages: [P2PMessage] = []
|
|
@State private var newMessage = ""
|
|
@State private var isLoading = false
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color("Background").ignoresSafeArea()
|
|
|
|
VStack(spacing: 0) {
|
|
// Messages
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
LazyVStack(spacing: 8) {
|
|
ForEach(messages) { message in
|
|
MessageBubble(
|
|
message: message,
|
|
isOutgoing: message.fromPhone != contactPhone
|
|
)
|
|
.id(message.id)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
.onChange(of: messages.count) { _ in
|
|
if let lastId = messages.last?.id {
|
|
withAnimation {
|
|
proxy.scrollTo(lastId, anchor: .bottom)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Input
|
|
HStack(spacing: 12) {
|
|
TextField("Сообщение...", text: $newMessage)
|
|
.padding(12)
|
|
.background(Color("Card"))
|
|
.cornerRadius(20)
|
|
|
|
Button(action: sendMessage) {
|
|
Image(systemName: "arrow.up.circle.fill")
|
|
.font(.system(size: 34))
|
|
.foregroundColor(newMessage.isEmpty ? .secondary : Color("Gold"))
|
|
}
|
|
.disabled(newMessage.isEmpty)
|
|
}
|
|
.padding()
|
|
.background(Color("Card").opacity(0.5))
|
|
}
|
|
}
|
|
.navigationTitle(contactName)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.onAppear {
|
|
loadMessages()
|
|
}
|
|
}
|
|
|
|
private func loadMessages() {
|
|
guard let deviceId = appState.deviceId else { return }
|
|
isLoading = true
|
|
|
|
API.shared.getMessages(deviceId: deviceId, withPhone: contactPhone) { result in
|
|
DispatchQueue.main.async {
|
|
isLoading = false
|
|
switch result {
|
|
case .success(let msgs):
|
|
messages = msgs.sorted { $0.timestamp < $1.timestamp }
|
|
case .failure:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sendMessage() {
|
|
guard let deviceId = appState.deviceId, !newMessage.isEmpty else { return }
|
|
let content = newMessage
|
|
newMessage = ""
|
|
|
|
API.shared.sendP2PMessage(deviceId: deviceId, toPhone: contactPhone, content: content) { result in
|
|
DispatchQueue.main.async {
|
|
switch result {
|
|
case .success(let message):
|
|
messages.append(message)
|
|
case .failure:
|
|
newMessage = content // Restore on failure
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Message Bubble
|
|
struct MessageBubble: View {
|
|
let message: P2PMessage
|
|
let isOutgoing: Bool
|
|
|
|
var body: some View {
|
|
HStack {
|
|
if isOutgoing { Spacer() }
|
|
|
|
VStack(alignment: isOutgoing ? .trailing : .leading, spacing: 4) {
|
|
Text(message.content)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
.background(isOutgoing ? Color("Gold") : Color("Card"))
|
|
.foregroundColor(isOutgoing ? Color("Background") : .white)
|
|
.cornerRadius(18)
|
|
|
|
Text(formatTime(message.timestamp))
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.frame(maxWidth: 280, alignment: isOutgoing ? .trailing : .leading)
|
|
|
|
if !isOutgoing { Spacer() }
|
|
}
|
|
}
|
|
|
|
private func formatTime(_ date: Date) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "HH:mm"
|
|
return formatter.string(from: date)
|
|
}
|
|
}
|