iOS update: ChatsView.swift

This commit is contained in:
efir369999 2026-05-05 17:16:26 +03:00
parent 5bafc410b2
commit 4f5f24f132

View File

@ -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: "", isOnline: true, isVerified: true),
ChatPreview(name: "Алёна К.", lastMessage: "Получила перевод, спасибо", timeAgo: "12м"),
ChatPreview(name: "Helsinki Node", lastMessage: "Reality TLS handshake — норм.", timeAgo: "", isOnline: true, isMuted: true, isVerified: true),
ChatPreview(name: "Команда Montana", lastMessage: "Деплой 3.42.0 на Frankfurt — готов.", timeAgo: "", 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))
.foregroundStyle(ClaudeTheme.Palette.textTertiary)
}
Spacer()
Text(chat.timeAgo)
if let last {
Text(formatTime(last.sentAt))
.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
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)
AvatarView(name: contact.name, size: 32)
VStack(alignment: .leading, spacing: 0) {
Text(chat.name)
Text(contact.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)
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 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)
}
}
private func sendCurrent() {
store.send(input, to: chat)
@MainActor
private func sendCurrent() async {
guard let id = identity.identity else { return }
let text = input
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)
.foregroundStyle(ClaudeTheme.Palette.textSecondary)
}
.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)
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)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
Text(formatTime(message.sentAt))
.font(.system(size: 10))
.foregroundStyle(message.isOutgoing
? Color.white.opacity(0.78)
.foregroundStyle(message.outgoing
? ClaudeTheme.Palette.bubbleOutText.opacity(0.7)
: 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)
}
.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<Bool>.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")
Button(action: onSend) {
Group {
if sending {
ProgressView().tint(ClaudeTheme.Palette.bubbleOutText)
} else {
Image(systemName: "arrow.up")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(Circle().fill(ClaudeTheme.Palette.accent))
.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 {
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()
}
.frame(maxWidth: .infinity)
} else {
ScrollView {
LazyVStack(spacing: 0) {
if !query.trimmingCharacters(in: .whitespaces).isEmpty &&
!suggestions.contains(where: { $0.localizedCaseInsensitiveContains(query) }) {
Button {
onSelect(query.trimmingCharacters(in: .whitespaces))
} label: {
ForEach(contacts.contacts) { c in
Button { onSelect(c) } label: {
HStack(spacing: 12) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 32))
.foregroundStyle(ClaudeTheme.Palette.accent)
AvatarView(name: c.name, size: 44)
VStack(alignment: .leading) {
Text("Начать чат с «\(query)»")
Text(c.name)
.font(ClaudeTheme.Typography.headline)
.foregroundStyle(ClaudeTheme.Palette.textPrimary)
Text("Новый собеседник")
.font(ClaudeTheme.Typography.caption)
Text(c.accountID)
.font(ClaudeTheme.Typography.mono)
.foregroundStyle(ClaudeTheme.Palette.textTertiary)
}
Spacer()
}
.padding(.horizontal, ClaudeTheme.Spacing.lg)
.padding(.vertical, ClaudeTheme.Spacing.md)
.contentShape(Rectangle())
}
Rectangle().fill(ClaudeTheme.Palette.divider).frame(height: 0.5)
Rectangle().fill(ClaudeTheme.Palette.divider)
.frame(height: 0.5)
.padding(.leading, 76)
}
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)
}
Spacer()
}
.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) }
}
}