montana/Montana-iOS/Montana Messenger/Montana Messenger/ChatsView.swift

448 lines
18 KiB
Swift

import SwiftUI
import Combine
struct ChatsView: View {
@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) {
Group {
if chatList.isEmpty {
emptyState
} else {
list
}
}
.claudeBackground()
Button { showNewChat = true } label: {
Image(systemName: "square.and.pencil")
.font(.system(size: 22, weight: .medium))
.foregroundStyle(ClaudeTheme.Palette.bubbleOutText)
.frame(width: 56, height: 56)
.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)
.navigationDestination(item: $openChat) { c in
ChatDetailView(contact: c)
}
.sheet(isPresented: $showNewChat) {
NewChatPickerView(onSelect: { c in
showNewChat = false
openChat = c
})
}
}
}
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)
}
}
}
private struct ChatRowButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(configuration.isPressed ? ClaudeTheme.Palette.surfaceHover : Color.clear)
}
}
private struct ChatRow: View {
let contact: Contact
let last: LocalMessage?
let unread: Int
var body: some View {
HStack(spacing: ClaudeTheme.Spacing.md) {
AvatarView(name: contact.name, size: 52)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Text(contact.name)
.font(ClaudeTheme.Typography.headline)
.foregroundStyle(ClaudeTheme.Palette.textPrimary)
.lineLimit(1)
Spacer()
if let last {
Text(formatTime(last.sentAt))
.font(ClaudeTheme.Typography.caption)
.foregroundStyle(ClaudeTheme.Palette.textTertiary)
}
}
HStack {
Text(last?.text ?? "Открыть разговор…")
.font(ClaudeTheme.Typography.callout)
.foregroundStyle(last == nil
? ClaudeTheme.Palette.textTertiary
: ClaudeTheme.Palette.textSecondary)
.lineLimit(1)
Spacer()
if unread > 0 {
Text("\(unread)")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(ClaudeTheme.Palette.bubbleOutText)
.frame(minWidth: 22, minHeight: 22)
.padding(.horizontal, 6)
.background(Capsule().fill(ClaudeTheme.Palette.goldGradient))
}
}
}
}
.padding(.horizontal, ClaudeTheme.Spacing.lg)
.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 contact: Contact
@EnvironmentObject private var identity: IdentityManager
@StateObject private var chats = ChatsStore.shared
@State private var input = ""
@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) {
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: messages.count) { _, _ in
if let last = messages.last {
withAnimation(.easeOut(duration: 0.2)) {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
.onAppear {
chats.markRead(contact.accountID)
if let last = messages.last {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
ComposeBar(text: $input, focused: $inputFocused,
sending: sending,
onSend: { Task { await sendCurrent() } })
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
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)
}
}
}
}
.toolbarBackground(ClaudeTheme.Palette.canvas, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
}
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)
}
}
@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: LocalMessage
var body: some View {
HStack {
if message.outgoing { Spacer(minLength: 48) }
VStack(alignment: .trailing, spacing: 2) {
Text(message.text)
.font(ClaudeTheme.Typography.body)
.foregroundStyle(message.outgoing
? ClaudeTheme.Palette.bubbleOutText
: ClaudeTheme.Palette.bubbleInText)
.padding(.horizontal, 12)
.padding(.top, 8)
.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.outgoing {
RoundedRectangle(cornerRadius: ClaudeTheme.Radius.bubble, style: .continuous)
.fill(ClaudeTheme.Palette.goldGradient)
} else {
RoundedRectangle(cornerRadius: ClaudeTheme.Radius.bubble, style: .continuous)
.fill(ClaudeTheme.Palette.surface)
.overlay(
RoundedRectangle(cornerRadius: ClaudeTheme.Radius.bubble, style: .continuous)
.strokeBorder(ClaudeTheme.Palette.goldHairline, lineWidth: 0.5)
)
}
}
)
.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
var body: some View {
HStack(spacing: ClaudeTheme.Spacing.sm) {
HStack {
TextField("Сообщение", text: $text, axis: .vertical)
.font(ClaudeTheme.Typography.body)
.foregroundStyle(ClaudeTheme.Palette.textPrimary)
.lineLimit(1...5)
.focused(focused)
.submitLabel(.send)
.onSubmit(onSend)
}
.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: 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)
.background(ClaudeTheme.Palette.canvas)
.overlay(alignment: .top) {
Rectangle().fill(ClaudeTheme.Palette.divider).frame(height: 0.5)
}
}
private var canSend: Bool {
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !sending
}
}
private struct NewChatPickerView: View {
@Environment(\.dismiss) private var dismiss
@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) {
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())
}
Rectangle().fill(ClaudeTheme.Palette.divider)
.frame(height: 0.5)
.padding(.leading, 76)
}
}
}
}
}
.claudeBackground()
.navigationTitle("Новый чат")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Отмена") { dismiss() }
.foregroundStyle(ClaudeTheme.Palette.textSecondary)
}
}
.toolbarBackground(ClaudeTheme.Palette.canvas, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
}
}
}