diff --git a/Montana-iOS/Montana Messenger/Montana Messenger/ContactsView.swift b/Montana-iOS/Montana Messenger/Montana Messenger/ContactsView.swift new file mode 100644 index 0000000..058e44f --- /dev/null +++ b/Montana-iOS/Montana Messenger/Montana Messenger/ContactsView.swift @@ -0,0 +1,186 @@ +import SwiftUI + +struct ContactsView: View { + @StateObject private var contacts = ContactsStore.shared + @State private var showAdd = false + @State private var openChat: Contact? + @State private var search = "" + + var body: some View { + NavigationStack { + ZStack(alignment: .bottomTrailing) { + Group { + if contacts.contacts.isEmpty { + emptyState + } else { + list + } + } + .claudeBackground() + + Button { showAdd = true } label: { + Image(systemName: "person.badge.plus") + .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("Контакты") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showAdd) { AddContactView() } + .navigationDestination(item: $openChat) { c in + ChatDetailView(contact: c) + } + } + } + + private var emptyState: some View { + VStack(spacing: ClaudeTheme.Spacing.lg) { + Spacer() + Image(systemName: "person.crop.circle.badge.plus") + .font(.system(size: 56)) + .foregroundStyle(ClaudeTheme.Palette.gold.opacity(0.7)) + VStack(spacing: 6) { + Text("Контактов пока нет") + .font(ClaudeTheme.Typography.title) + .foregroundStyle(ClaudeTheme.Palette.textPrimary) + Text("Добавь собеседника по Account ID, чтобы начать.") + .font(ClaudeTheme.Typography.callout) + .foregroundStyle(ClaudeTheme.Palette.textTertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var list: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(filtered) { c in + Button { openChat = c } label: { + HStack(spacing: 12) { + AvatarView(name: c.name, size: 48) + VStack(alignment: .leading, spacing: 2) { + Text(c.name) + .font(ClaudeTheme.Typography.headline) + .foregroundStyle(ClaudeTheme.Palette.textPrimary) + Text(c.accountID) + .font(ClaudeTheme.Typography.mono) + .foregroundStyle(ClaudeTheme.Palette.textTertiary) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(ClaudeTheme.Palette.textTertiary) + } + .padding(.horizontal, ClaudeTheme.Spacing.lg) + .padding(.vertical, ClaudeTheme.Spacing.md) + .contentShape(Rectangle()) + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + contacts.delete(c.accountID) + } label: { + Label("Удалить", systemImage: "trash") + } + } + Rectangle().fill(ClaudeTheme.Palette.divider) + .frame(height: 0.5) + .padding(.leading, 76) + } + } + } + .searchable(text: $search, prompt: "Поиск") + } + + private var filtered: [Contact] { + guard !search.trimmingCharacters(in: .whitespaces).isEmpty else { return contacts.contacts } + return contacts.contacts.filter { + $0.name.localizedCaseInsensitiveContains(search) || + $0.accountID.localizedCaseInsensitiveContains(search) + } + } +} + +private struct AddContactView: View { + @Environment(\.dismiss) private var dismiss + @State private var input = "" + @State private var error: String? + @State private var working = false + @FocusState private var focused: Bool + + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: ClaudeTheme.Spacing.lg) { + Text("Account ID собеседника") + .font(ClaudeTheme.Typography.headline) + .foregroundStyle(ClaudeTheme.Palette.textPrimary) + .padding(.top, ClaudeTheme.Spacing.md) + + TextField("например, 7a3f9b1c8d4e2f0a", text: $input) + .font(ClaudeTheme.Typography.mono) + .foregroundStyle(ClaudeTheme.Palette.textPrimary) + .focused($focused) + .autocapitalization(.none) + .disableAutocorrection(true) + .padding(14) + .background(RoundedRectangle(cornerRadius: ClaudeTheme.Radius.md).fill(ClaudeTheme.Palette.surface)) + .overlay(RoundedRectangle(cornerRadius: ClaudeTheme.Radius.md).strokeBorder(focused ? ClaudeTheme.Palette.gold : ClaudeTheme.Palette.goldHairline, lineWidth: 1)) + + if let error { + Text(error) + .font(ClaudeTheme.Typography.caption) + .foregroundStyle(ClaudeTheme.Palette.danger) + } + + Button { Task { await add() } } label: { + Text(working ? "Добавление..." : "Добавить") + .font(ClaudeTheme.Typography.headline) + .foregroundStyle(ClaudeTheme.Palette.bubbleOutText) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.Radius.md, style: .continuous) + .fill(input.isEmpty + ? AnyShapeStyle(ClaudeTheme.Palette.surface) + : AnyShapeStyle(ClaudeTheme.Palette.goldGradient)) + ) + } + .disabled(input.isEmpty || working) + + Spacer() + } + .padding(.horizontal, ClaudeTheme.Spacing.lg) + .claudeBackground() + .navigationTitle("Новый контакт") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Отмена") { dismiss() } + .foregroundStyle(ClaudeTheme.Palette.textSecondary) + } + } + .toolbarBackground(ClaudeTheme.Palette.canvas, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .onAppear { focused = true } + } + } + + @MainActor + private func add() async { + working = true; error = nil + defer { working = false } + do { + _ = try await ContactsStore.shared.addByID(input) + dismiss() + } catch { + self.error = error.localizedDescription + } + } +}