From 4f5f24f1329b8a6e980c70f8e2ae3da48b016056 Mon Sep 17 00:00:00 2001 From: efir369999 Date: Tue, 5 May 2026 17:16:26 +0300 Subject: [PATCH] iOS update: ChatsView.swift --- .../Montana Messenger/ChatsView.swift | 672 +++++++----------- 1 file changed, 246 insertions(+), 426 deletions(-) diff --git a/Montana-iOS/Montana Messenger/Montana Messenger/ChatsView.swift b/Montana-iOS/Montana Messenger/Montana Messenger/ChatsView.swift index 900de92..889036f 100644 --- a/Montana-iOS/Montana Messenger/Montana Messenger/ChatsView.swift +++ b/Montana-iOS/Montana Messenger/Montana Messenger/ChatsView.swift @@ -1,185 +1,106 @@ import SwiftUI import Combine -struct ChatPreview: Identifiable, Hashable { - let id: UUID - var name: String - var lastMessage: String - var timeAgo: String - var unread: Int - var isOnline: Bool - var isMuted: Bool - var isVerified: Bool - - init(id: UUID = UUID(), name: String, lastMessage: String, timeAgo: String, - unread: Int = 0, isOnline: Bool = false, isMuted: Bool = false, isVerified: Bool = false) { - self.id = id; self.name = name; self.lastMessage = lastMessage - self.timeAgo = timeAgo; self.unread = unread - self.isOnline = isOnline; self.isMuted = isMuted; self.isVerified = isVerified - } -} - -struct ChatMessage: Identifiable, Hashable { - let id: UUID - let text: String - let isOutgoing: Bool - let time: String - var read: Bool - - init(text: String, isOutgoing: Bool, time: String, read: Bool = false) { - self.id = UUID(); self.text = text; self.isOutgoing = isOutgoing - self.time = time; self.read = read - } -} - -@MainActor -final class ChatsStore: ObservableObject { - static let shared = ChatsStore() - - @Published var chats: [ChatPreview] = [ - ChatPreview(name: "Юнона", lastMessage: "Окно закрыто. Якорь в мос-фра-зел совпал.", timeAgo: "сейчас", unread: 2, isOnline: true, isVerified: true), - ChatPreview(name: "Frankfurt Node", lastMessage: "VDF готов через 1.2с. Передаю в очередь.", timeAgo: "3м", isOnline: true, isVerified: true), - ChatPreview(name: "Алёна К.", lastMessage: "Получила перевод, спасибо", timeAgo: "12м"), - ChatPreview(name: "Helsinki Node", lastMessage: "Reality TLS handshake — норм.", timeAgo: "1ч", isOnline: true, isMuted: true, isVerified: true), - ChatPreview(name: "Команда Montana", lastMessage: "Деплой 3.42.0 на Frankfurt — готов.", timeAgo: "3ч", unread: 5), - ChatPreview(name: "Moscow Node", lastMessage: "efir.org поднят на :8443.", timeAgo: "вчера", isOnline: true, isMuted: true, isVerified: true), - ChatPreview(name: "Игорь Б.", lastMessage: "Скинь mnemonic как восстановишь.", timeAgo: "вчера"), - ChatPreview(name: "TimeChain Bot", lastMessage: "Window 2891845 sealed at 14:22:11 UTC", timeAgo: "пн", isOnline: true, isMuted: true, isVerified: true), - ] - - @Published private var messagesByChat: [UUID: [ChatMessage]] = [:] - - func messages(for chat: ChatPreview) -> [ChatMessage] { - if let m = messagesByChat[chat.id] { return m } - let seed: [ChatMessage] = [ - ChatMessage(text: "Привет. Окно \(Int.random(in: 2_891_800...2_891_900)) закрыто.", isOutgoing: false, time: "14:21", read: true), - ChatMessage(text: "VDF подтверждён всеми тремя узлами.", isOutgoing: false, time: "14:21", read: true), - ChatMessage(text: "Принял. Ставим якорь?", isOutgoing: true, time: "14:22", read: true), - ChatMessage(text: "Да, якорь установлен. мос-фра-зел совпали.", isOutgoing: false, time: "14:22", read: true), - ] - messagesByChat[chat.id] = seed - return seed - } - - func send(_ text: String, to chat: ChatPreview) { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - let f = DateFormatter(); f.dateFormat = "HH:mm" - let msg = ChatMessage(text: trimmed, isOutgoing: true, time: f.string(from: Date()), read: false) - messagesByChat[chat.id, default: []].append(msg) - if let i = chats.firstIndex(where: { $0.id == chat.id }) { - chats[i].lastMessage = trimmed - chats[i].timeAgo = "сейчас" - chats[i].unread = 0 - let updated = chats.remove(at: i) - chats.insert(updated, at: 0) - } - } - - func markRead(_ chat: ChatPreview) { - if let i = chats.firstIndex(where: { $0.id == chat.id }) { - chats[i].unread = 0 - } - } - - func togglePin(_ chat: ChatPreview) { - if let i = chats.firstIndex(where: { $0.id == chat.id }) { - let item = chats.remove(at: i) - chats.insert(item, at: 0) - } - } - - func toggleMute(_ chat: ChatPreview) { - if let i = chats.firstIndex(where: { $0.id == chat.id }) { - chats[i].isMuted.toggle() - } - } - - func delete(_ chat: ChatPreview) { - chats.removeAll { $0.id == chat.id } - messagesByChat[chat.id] = nil - } - - func startChat(with name: String) -> ChatPreview { - if let existing = chats.first(where: { $0.name.lowercased() == name.lowercased() }) { - return existing - } - let new = ChatPreview(name: name, lastMessage: "Новый разговор", timeAgo: "сейчас", isOnline: false) - chats.insert(new, at: 0) - return new - } -} - struct ChatsView: View { - @StateObject private var store = ChatsStore.shared - @State private var searchText = "" + @EnvironmentObject private var identity: IdentityManager + @StateObject private var chats = ChatsStore.shared + @StateObject private var contacts = ContactsStore.shared + @State private var search = "" + @State private var openChat: Contact? @State private var showNewChat = false var body: some View { NavigationStack { ZStack(alignment: .bottomTrailing) { - ScrollView { - LazyVStack(spacing: 0) { - ForEach(filteredChats) { chat in - NavigationLink(value: chat) { ChatRow(chat: chat) } - .buttonStyle(ChatRowButtonStyle()) - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(role: .destructive) { store.delete(chat) } label: { - Label("Удалить", systemImage: "trash") - } - Button { store.toggleMute(chat) } label: { - Label(chat.isMuted ? "Включить" : "Mute", - systemImage: chat.isMuted ? "speaker.wave.2.fill" : "speaker.slash.fill") - }.tint(ClaudeTheme.Palette.accent) - } - .swipeActions(edge: .leading) { - Button { store.togglePin(chat) } label: { - Label("Закрепить", systemImage: "pin.fill") - }.tint(ClaudeTheme.Palette.success) - } - Rectangle() - .fill(ClaudeTheme.Palette.divider) - .frame(height: 0.5) - .padding(.leading, 80) - } + Group { + if chatList.isEmpty { + emptyState + } else { + list } } .claudeBackground() - .searchable(text: $searchText, prompt: "Поиск") Button { showNewChat = true } label: { Image(systemName: "square.and.pencil") .font(.system(size: 22, weight: .medium)) - .foregroundStyle(.white) + .foregroundStyle(ClaudeTheme.Palette.bubbleOutText) .frame(width: 56, height: 56) - .background(Circle().fill(ClaudeTheme.Palette.accent)) - .shadow(color: .black.opacity(0.45), radius: 14, y: 6) + .background(Circle().fill(ClaudeTheme.Palette.goldGradient)) + .shadow(color: ClaudeTheme.Palette.gold.opacity(0.45), radius: 16, y: 4) } .padding(.trailing, ClaudeTheme.Spacing.lg) .padding(.bottom, ClaudeTheme.Spacing.lg) } .navigationTitle("Montana") .navigationBarTitleDisplayMode(.inline) - .toolbarBackground(ClaudeTheme.Palette.canvas, for: .navigationBar) - .toolbarBackground(.visible, for: .navigationBar) - .navigationDestination(for: ChatPreview.self) { chat in - ChatDetailView(chat: chat).environmentObject(store) - .onAppear { store.markRead(chat) } + .navigationDestination(item: $openChat) { c in + ChatDetailView(contact: c) } .sheet(isPresented: $showNewChat) { - NewChatView { name in + NewChatPickerView(onSelect: { c in showNewChat = false - _ = store.startChat(with: name) - } - .environmentObject(store) + openChat = c + }) } } } - private var filteredChats: [ChatPreview] { - guard !searchText.isEmpty else { return store.chats } - return store.chats.filter { $0.name.localizedCaseInsensitiveContains(searchText) || - $0.lastMessage.localizedCaseInsensitiveContains(searchText) } + private var emptyState: some View { + VStack(spacing: ClaudeTheme.Spacing.lg) { + Spacer() + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 56)) + .foregroundStyle(ClaudeTheme.Palette.gold.opacity(0.7)) + VStack(spacing: 6) { + Text("Чатов пока нет") + .font(ClaudeTheme.Typography.title) + .foregroundStyle(ClaudeTheme.Palette.textPrimary) + Text("Добавь контакт и начни разговор.") + .font(ClaudeTheme.Typography.callout) + .foregroundStyle(ClaudeTheme.Palette.textTertiary) + } + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var list: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(filtered, id: \.peer.accountID) { item in + Button { openChat = item.peer } label: { + ChatRow(contact: item.peer, last: item.last, + unread: chats.unreadByPeer[item.peer.accountID] ?? 0) + } + .buttonStyle(ChatRowButtonStyle()) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + ContactsStore.shared.delete(item.peer.accountID) + } label: { + Label("Удалить", systemImage: "trash") + } + } + Rectangle().fill(ClaudeTheme.Palette.divider) + .frame(height: 0.5) + .padding(.leading, 80) + } + } + } + .searchable(text: $search, prompt: "Поиск") + } + + private var chatList: [(peer: Contact, last: LocalMessage?)] { + chats.chats() + } + + private var filtered: [(peer: Contact, last: LocalMessage?)] { + guard !search.trimmingCharacters(in: .whitespaces).isEmpty else { return chatList } + return chatList.filter { + $0.peer.name.localizedCaseInsensitiveContains(search) || + $0.peer.accountID.localizedCaseInsensitiveContains(search) || + ($0.last?.text.localizedCaseInsensitiveContains(search) ?? false) + } } } @@ -191,58 +112,42 @@ private struct ChatRowButtonStyle: ButtonStyle { } private struct ChatRow: View { - let chat: ChatPreview + let contact: Contact + let last: LocalMessage? + let unread: Int var body: some View { HStack(spacing: ClaudeTheme.Spacing.md) { - ZStack(alignment: .bottomTrailing) { - AvatarView(name: chat.name, size: 52) - if chat.isOnline { - Circle() - .fill(ClaudeTheme.Palette.success) - .frame(width: 14, height: 14) - .overlay(Circle().stroke(ClaudeTheme.Palette.canvas, lineWidth: 2.5)) - } - } + AvatarView(name: contact.name, size: 52) VStack(alignment: .leading, spacing: 4) { HStack(spacing: 4) { - Text(chat.name) + Text(contact.name) .font(ClaudeTheme.Typography.headline) .foregroundStyle(ClaudeTheme.Palette.textPrimary) .lineLimit(1) - if chat.isVerified { - Image(systemName: "checkmark.seal.fill") - .font(.system(size: 12)) - .foregroundStyle(ClaudeTheme.Palette.accent) - } - if chat.isMuted { - Image(systemName: "speaker.slash.fill") - .font(.system(size: 11)) + Spacer() + if let last { + Text(formatTime(last.sentAt)) + .font(ClaudeTheme.Typography.caption) .foregroundStyle(ClaudeTheme.Palette.textTertiary) } - Spacer() - Text(chat.timeAgo) - .font(ClaudeTheme.Typography.caption) - .foregroundStyle(ClaudeTheme.Palette.textTertiary) } HStack { - Text(chat.lastMessage) + Text(last?.text ?? "Открыть разговор…") .font(ClaudeTheme.Typography.callout) - .foregroundStyle(ClaudeTheme.Palette.textSecondary) + .foregroundStyle(last == nil + ? ClaudeTheme.Palette.textTertiary + : ClaudeTheme.Palette.textSecondary) .lineLimit(1) Spacer() - if chat.unread > 0 { - Text("\(chat.unread)") + if unread > 0 { + Text("\(unread)") .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.white) + .foregroundStyle(ClaudeTheme.Palette.bubbleOutText) .frame(minWidth: 22, minHeight: 22) .padding(.horizontal, 6) - .background( - Capsule().fill(chat.isMuted - ? ClaudeTheme.Palette.textTertiary - : ClaudeTheme.Palette.accent) - ) + .background(Capsule().fill(ClaudeTheme.Palette.goldGradient)) } } } @@ -251,233 +156,148 @@ private struct ChatRow: View { .padding(.vertical, ClaudeTheme.Spacing.md) .contentShape(Rectangle()) } + + private func formatTime(_ ms: Int) -> String { + let d = Date(timeIntervalSince1970: TimeInterval(ms) / 1000) + let f = DateFormatter() + if Calendar.current.isDateInToday(d) { + f.dateFormat = "HH:mm" + } else if Calendar.current.isDateInYesterday(d) { + return "вчера" + } else { + f.dateFormat = "d MMM" + } + return f.string(from: d) + } } struct ChatDetailView: View { - let chat: ChatPreview - @EnvironmentObject private var store: ChatsStore + let contact: Contact + @EnvironmentObject private var identity: IdentityManager + @StateObject private var chats = ChatsStore.shared @State private var input = "" - @State private var showAttach = false - @State private var showCallAlert = false - @State private var showInfo = false + @State private var sending = false @FocusState private var inputFocused: Bool + var messages: [LocalMessage] { + chats.messages(for: contact.accountID) + } + var body: some View { VStack(spacing: 0) { ScrollViewReader { proxy in ScrollView { VStack(spacing: 6) { - ForEach(store.messages(for: chat)) { msg in - MessageBubble(message: msg).id(msg.id) + if messages.isEmpty { + emptyState + .padding(.top, 80) + } else { + ForEach(messages) { msg in + MessageBubble(message: msg).id(msg.id) + } } } .padding(.horizontal, ClaudeTheme.Spacing.md) .padding(.vertical, ClaudeTheme.Spacing.md) } .claudeBackground() - .onChange(of: store.messages(for: chat).count) { _, _ in - if let last = store.messages(for: chat).last { + .onChange(of: messages.count) { _, _ in + if let last = messages.last { withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(last.id, anchor: .bottom) } } } .onAppear { - if let last = store.messages(for: chat).last { + chats.markRead(contact.accountID) + if let last = messages.last { proxy.scrollTo(last.id, anchor: .bottom) } } } ComposeBar(text: $input, focused: $inputFocused, - onSend: { sendCurrent() }, - onAttach: { showAttach = true }) + sending: sending, + onSend: { Task { await sendCurrent() } }) } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { - Button { showInfo = true } label: { - HStack(spacing: 10) { - AvatarView(name: chat.name, size: 32) - VStack(alignment: .leading, spacing: 0) { - Text(chat.name) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(ClaudeTheme.Palette.textPrimary) - Text(chat.isOnline ? "в сети" : "был(а) недавно") - .font(.system(size: 11)) - .foregroundStyle(chat.isOnline ? ClaudeTheme.Palette.success : ClaudeTheme.Palette.textTertiary) - } + HStack(spacing: 10) { + AvatarView(name: contact.name, size: 32) + VStack(alignment: .leading, spacing: 0) { + Text(contact.name) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(ClaudeTheme.Palette.textPrimary) + Text(contact.accountID) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(ClaudeTheme.Palette.textTertiary) } } } - ToolbarItem(placement: .topBarTrailing) { - Button { showCallAlert = true } label: { - Image(systemName: "phone.fill") - .foregroundStyle(ClaudeTheme.Palette.accent) - } - } - } - .toolbarBackground(ClaudeTheme.Palette.canvas, for: .navigationBar) - .toolbarBackground(.visible, for: .navigationBar) - .alert("Звонок", isPresented: $showCallAlert) { - Button("Отмена", role: .cancel) {} - Button("Позвонить") {} - } message: { - Text("Зашифрованный голосовой звонок \(chat.name) через сеть Montana.") - } - .confirmationDialog("Прикрепить", isPresented: $showAttach, titleVisibility: .visible) { - Button("Фото или видео") { sendStub("📷 Фото") } - Button("Файл") { sendStub("📎 Файл") } - Button("Геолокация") { sendStub("📍 Москва, центр") } - Button("Контакт") { sendStub("👤 Контакт") } - Button("Перевод TimeCoin") { sendStub("💸 100 ₮ → \(chat.name)") } - Button("Отмена", role: .cancel) {} - } - .sheet(isPresented: $showInfo) { - NavigationStack { - ChatInfoView(chat: chat) - } - } - } - - private func sendCurrent() { - store.send(input, to: chat) - input = "" - } - - private func sendStub(_ text: String) { - store.send(text, to: chat) - } -} - -private struct ChatInfoView: View { - let chat: ChatPreview - @Environment(\.dismiss) private var dismiss - - var body: some View { - ScrollView { - VStack(spacing: ClaudeTheme.Spacing.lg) { - AvatarView(name: chat.name, size: 96) - .padding(.top, ClaudeTheme.Spacing.xl) - Text(chat.name) - .font(ClaudeTheme.Typography.title) - .foregroundStyle(ClaudeTheme.Palette.textPrimary) - Text(chat.isOnline ? "в сети" : "был(а) недавно") - .font(ClaudeTheme.Typography.caption) - .foregroundStyle(chat.isOnline ? ClaudeTheme.Palette.success : ClaudeTheme.Palette.textTertiary) - - HStack(spacing: ClaudeTheme.Spacing.lg) { - InfoAction(icon: "phone.fill", label: "Звонок") - InfoAction(icon: "video.fill", label: "Видео") - InfoAction(icon: "magnifyingglass", label: "Поиск") - InfoAction(icon: "speaker.slash.fill", label: chat.isMuted ? "Unmute" : "Mute") - } - - VStack(spacing: 0) { - InfoRow(icon: "shield.lefthalf.filled", title: "Шифрование", value: "ML-DSA-65") - InfoRow(icon: "clock.fill", title: "Создан", value: "Окно 2891840") - InfoRow(icon: "person.fill", title: "Account ID", value: "0x7a3f…b921") - } - .background( - RoundedRectangle(cornerRadius: ClaudeTheme.Radius.md, style: .continuous) - .fill(ClaudeTheme.Palette.surface) - ) - .padding(.horizontal, ClaudeTheme.Spacing.lg) - - Spacer(minLength: 40) - } - } - .claudeBackground() - .navigationTitle("Информация") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Готово") { dismiss() } - .foregroundStyle(ClaudeTheme.Palette.accent) - } } .toolbarBackground(ClaudeTheme.Palette.canvas, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) } -} -private struct InfoAction: View { - let icon: String; let label: String - var body: some View { - Button {} label: { - VStack(spacing: 6) { - Image(systemName: icon) - .font(.system(size: 18)) - .foregroundStyle(ClaudeTheme.Palette.accent) - .frame(width: 48, height: 48) - .background(Circle().fill(ClaudeTheme.Palette.surface)) - Text(label) - .font(ClaudeTheme.Typography.caption) - .foregroundStyle(ClaudeTheme.Palette.textSecondary) - } - } - } -} - -private struct InfoRow: View { - let icon: String; let title: String; let value: String - var body: some View { - HStack(spacing: 12) { - Image(systemName: icon) - .font(.system(size: 14)) - .foregroundStyle(ClaudeTheme.Palette.accent) - .frame(width: 28) - Text(title) - .font(ClaudeTheme.Typography.body) - .foregroundStyle(ClaudeTheme.Palette.textPrimary) - Spacer() - Text(value) - .font(ClaudeTheme.Typography.callout) + private var emptyState: some View { + VStack(spacing: 8) { + Image(systemName: "lock.shield.fill") + .font(.system(size: 36)) + .foregroundStyle(ClaudeTheme.Palette.gold.opacity(0.6)) + Text("Сообщения зашифрованы") + .font(ClaudeTheme.Typography.headline) .foregroundStyle(ClaudeTheme.Palette.textSecondary) + Text("Никто кроме вас двоих не прочтёт переписку.") + .font(ClaudeTheme.Typography.caption) + .foregroundStyle(ClaudeTheme.Palette.textTertiary) } - .padding(.horizontal, ClaudeTheme.Spacing.md) - .padding(.vertical, ClaudeTheme.Spacing.md) - .overlay(alignment: .bottom) { - Rectangle().fill(ClaudeTheme.Palette.divider).frame(height: 0.5).padding(.leading, 44) + } + + @MainActor + private func sendCurrent() async { + guard let id = identity.identity else { return } + let text = input + input = "" + sending = true + defer { sending = false } + do { + try await chats.send(text: text, to: contact, identity: id) + } catch { + input = text } } } private struct MessageBubble: View { - let message: ChatMessage + let message: LocalMessage var body: some View { HStack { - if message.isOutgoing { Spacer(minLength: 48) } + if message.outgoing { Spacer(minLength: 48) } VStack(alignment: .trailing, spacing: 2) { Text(message.text) .font(ClaudeTheme.Typography.body) - .foregroundStyle(message.isOutgoing + .foregroundStyle(message.outgoing ? ClaudeTheme.Palette.bubbleOutText : ClaudeTheme.Palette.bubbleInText) .padding(.horizontal, 12) .padding(.top, 8) - HStack(spacing: 3) { - Text(message.time) - .font(.system(size: 10)) - .foregroundStyle(message.isOutgoing - ? Color.white.opacity(0.78) - : ClaudeTheme.Palette.textTertiary) - if message.isOutgoing { - Image(systemName: message.read ? "checkmark.circle.fill" : "checkmark") - .font(.system(size: 10)) - .foregroundStyle(Color.white.opacity(0.78)) - } - } - .padding(.horizontal, 12) - .padding(.bottom, 6) - .padding(.top, 0) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + Text(formatTime(message.sentAt)) + .font(.system(size: 10)) + .foregroundStyle(message.outgoing + ? ClaudeTheme.Palette.bubbleOutText.opacity(0.7) + : ClaudeTheme.Palette.textTertiary) + .padding(.horizontal, 12) + .padding(.bottom, 6) } .background( Group { - if message.isOutgoing { + if message.outgoing { RoundedRectangle(cornerRadius: ClaudeTheme.Radius.bubble, style: .continuous) - .fill(ClaudeTheme.Palette.bubbleOutGradient) + .fill(ClaudeTheme.Palette.goldGradient) } else { RoundedRectangle(cornerRadius: ClaudeTheme.Radius.bubble, style: .continuous) .fill(ClaudeTheme.Palette.surface) @@ -488,26 +308,27 @@ private struct MessageBubble: View { } } ) - if !message.isOutgoing { Spacer(minLength: 48) } + .frame(maxWidth: 280, alignment: message.outgoing ? .trailing : .leading) + if !message.outgoing { Spacer(minLength: 48) } } } + + private func formatTime(_ ms: Int) -> String { + let d = Date(timeIntervalSince1970: TimeInterval(ms) / 1000) + let f = DateFormatter() + f.dateFormat = "HH:mm" + return f.string(from: d) + } } private struct ComposeBar: View { @Binding var text: String var focused: FocusState.Binding + let sending: Bool let onSend: () -> Void - let onAttach: () -> Void var body: some View { HStack(spacing: ClaudeTheme.Spacing.sm) { - Button(action: onAttach) { - Image(systemName: "paperclip") - .font(.system(size: 20)) - .foregroundStyle(ClaudeTheme.Palette.textTertiary) - } - .padding(.leading, ClaudeTheme.Spacing.md) - HStack { TextField("Сообщение", text: $text, axis: .vertical) .font(ClaudeTheme.Typography.body) @@ -516,24 +337,37 @@ private struct ComposeBar: View { .focused(focused) .submitLabel(.send) .onSubmit(onSend) - Image(systemName: "face.smiling") - .font(.system(size: 18)) - .foregroundStyle(ClaudeTheme.Palette.textTertiary) } - .padding(.horizontal, 12) - .padding(.vertical, 8) + .padding(.horizontal, 14) + .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: 18, style: .continuous) .fill(ClaudeTheme.Palette.surface) ) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(ClaudeTheme.Palette.goldHairline, lineWidth: 0.5) + ) + .padding(.leading, ClaudeTheme.Spacing.md) - Button(action: { if !text.isEmpty { onSend() } }) { - Image(systemName: text.isEmpty ? "mic.fill" : "arrow.up") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 36, height: 36) - .background(Circle().fill(ClaudeTheme.Palette.accent)) + Button(action: onSend) { + Group { + if sending { + ProgressView().tint(ClaudeTheme.Palette.bubbleOutText) + } else { + Image(systemName: "arrow.up") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(ClaudeTheme.Palette.bubbleOutText) + } + } + .frame(width: 36, height: 36) + .background( + Circle().fill(canSend + ? AnyShapeStyle(ClaudeTheme.Palette.goldGradient) + : AnyShapeStyle(ClaudeTheme.Palette.surface)) + ) } + .disabled(!canSend) .padding(.trailing, ClaudeTheme.Spacing.md) } .padding(.vertical, 8) @@ -542,71 +376,62 @@ private struct ComposeBar: View { Rectangle().fill(ClaudeTheme.Palette.divider).frame(height: 0.5) } } + + private var canSend: Bool { + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !sending + } } -struct NewChatView: View { - @EnvironmentObject private var store: ChatsStore +private struct NewChatPickerView: View { @Environment(\.dismiss) private var dismiss - let onSelect: (String) -> Void - @State private var query = "" - - private let suggestions = [ - "Алик Монтана", "Юнона", "Алёна К.", "Игорь Б.", - "Frankfurt Node", "Helsinki Node", "Moscow Node", "TimeChain Bot", - ] + @StateObject private var contacts = ContactsStore.shared + let onSelect: (Contact) -> Void var body: some View { NavigationStack { - ScrollView { - LazyVStack(spacing: 0) { - if !query.trimmingCharacters(in: .whitespaces).isEmpty && - !suggestions.contains(where: { $0.localizedCaseInsensitiveContains(query) }) { - Button { - onSelect(query.trimmingCharacters(in: .whitespaces)) - } label: { - HStack(spacing: 12) { - Image(systemName: "plus.circle.fill") - .font(.system(size: 32)) - .foregroundStyle(ClaudeTheme.Palette.accent) - VStack(alignment: .leading) { - Text("Начать чат с «\(query)»") - .font(ClaudeTheme.Typography.headline) - .foregroundStyle(ClaudeTheme.Palette.textPrimary) - Text("Новый собеседник") - .font(ClaudeTheme.Typography.caption) - .foregroundStyle(ClaudeTheme.Palette.textTertiary) - } - Spacer() - } - .padding(.horizontal, ClaudeTheme.Spacing.lg) - .padding(.vertical, ClaudeTheme.Spacing.md) - } - Rectangle().fill(ClaudeTheme.Palette.divider).frame(height: 0.5) + Group { + if contacts.contacts.isEmpty { + VStack(spacing: 12) { + Spacer() + Image(systemName: "person.crop.circle") + .font(.system(size: 56)) + .foregroundStyle(ClaudeTheme.Palette.gold.opacity(0.5)) + Text("Сначала добавь контакт") + .font(ClaudeTheme.Typography.headline) + .foregroundStyle(ClaudeTheme.Palette.textSecondary) + Spacer() } - - ForEach(filtered, id: \.self) { name in - Button { onSelect(name) } label: { - HStack(spacing: 12) { - AvatarView(name: name, size: 44) - VStack(alignment: .leading) { - Text(name) - .font(ClaudeTheme.Typography.headline) - .foregroundStyle(ClaudeTheme.Palette.textPrimary) - Text("в сети") - .font(ClaudeTheme.Typography.caption) - .foregroundStyle(ClaudeTheme.Palette.success) + .frame(maxWidth: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(contacts.contacts) { c in + Button { onSelect(c) } label: { + HStack(spacing: 12) { + AvatarView(name: c.name, size: 44) + VStack(alignment: .leading) { + Text(c.name) + .font(ClaudeTheme.Typography.headline) + .foregroundStyle(ClaudeTheme.Palette.textPrimary) + Text(c.accountID) + .font(ClaudeTheme.Typography.mono) + .foregroundStyle(ClaudeTheme.Palette.textTertiary) + } + Spacer() + } + .padding(.horizontal, ClaudeTheme.Spacing.lg) + .padding(.vertical, ClaudeTheme.Spacing.md) + .contentShape(Rectangle()) } - Spacer() + Rectangle().fill(ClaudeTheme.Palette.divider) + .frame(height: 0.5) + .padding(.leading, 76) } - .padding(.horizontal, ClaudeTheme.Spacing.lg) - .padding(.vertical, ClaudeTheme.Spacing.md) } - Rectangle().fill(ClaudeTheme.Palette.divider).frame(height: 0.5).padding(.leading, 76) } } } .claudeBackground() - .searchable(text: $query, prompt: "Имя или Account ID") .navigationTitle("Новый чат") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -619,9 +444,4 @@ struct NewChatView: View { .toolbarBackground(.visible, for: .navigationBar) } } - - private var filtered: [String] { - guard !query.isEmpty else { return suggestions } - return suggestions.filter { $0.localizedCaseInsensitiveContains(query) } - } }