import SwiftUI struct MainView: View { @ObservedObject var viewModel: LoginViewModel @State private var selectedTab: Int = 0 // @StateObject private var newHomeTabViewModel = NewHomeTabViewModel() // Состояния для TopBarView @State private var selectedAccount = "@user1" @State private var accounts = ["@user1", "@user2", "@user3"] // @State private var sheetType: ProfileTab.SheetType? = nil // Состояния для бокового меню @State private var isSideMenuPresented = false @State private var menuOffset: CGFloat = 0 @State private var chatSearchRevealProgress: CGFloat = 0 @State private var chatSearchText: String = "" @State private var pendingChatDeepLink: ChatDeepLink? @State private var incomingMessageBanner: IncomingMessageBanner? @State private var bannerDismissWorkItem: DispatchWorkItem? private var tabTitle: String { switch selectedTab { case 0: return "Home" case 1: return "Concept" case 2: return "Chats" case 3: return "Profile" default: return "Home" } } private var menuWidth: CGFloat { UIScreen.main.bounds.width * 0.8 } var body: some View { NavigationView { ZStack(alignment: .top) { ZStack(alignment: .leading) { // Выравниваем ZStack по левому краю // Основной контент VStack(spacing: 0) { TopBarView( title: tabTitle, selectedAccount: $selectedAccount, accounts: accounts, viewModel: viewModel, isSideMenuPresented: $isSideMenuPresented, chatSearchRevealProgress: $chatSearchRevealProgress, chatSearchText: $chatSearchText ) ZStack { NewHomeTab() .opacity(selectedTab == 0 ? 1 : 0) ConceptTab() .opacity(selectedTab == 1 ? 1 : 0) ChatsTab( loginViewModel: viewModel, pendingDeepLink: $pendingChatDeepLink, searchRevealProgress: $chatSearchRevealProgress, searchText: $chatSearchText ) .opacity(selectedTab == 2 ? 1 : 0) .allowsHitTesting(selectedTab == 2) ProfileTab() .opacity(selectedTab == 3 ? 1 : 0) } .frame(maxWidth: .infinity, maxHeight: .infinity) CustomTabBar(selectedTab: $selectedTab) { print("Create button tapped") } } .frame(maxWidth: .infinity, maxHeight: .infinity) // Убедимся, что основной контент занимает все пространство .ignoresSafeArea(edges: .bottom) .navigationBarHidden(true) // .sheet(item: $sheetType) { type in // // ... sheet presentation logic // } // Затемнение и закрытие по тапу Color.black .opacity(Double(menuOffset / menuWidth) * 0.4) .ignoresSafeArea() .onTapGesture { withAnimation(.easeInOut) { isSideMenuPresented = false } } .allowsHitTesting(menuOffset > 0) // Боковое меню SideMenuView(viewModel: viewModel, isPresented: $isSideMenuPresented) .frame(width: menuWidth) .offset(x: -menuWidth + menuOffset) // Новая логика смещения .ignoresSafeArea(edges: .vertical) } if let banner = incomingMessageBanner { NewMessageBannerView( senderName: banner.senderName, messagePreview: banner.messagePreview, onOpen: { openChat(from: banner) }, onDismiss: { dismissIncomingBanner() } ) .padding(.horizontal, 16) .padding(.top, 12) .transition(.move(edge: .top).combined(with: .opacity)) .zIndex(1) } } .gesture( DragGesture() .onChanged { gesture in if !isSideMenuPresented && gesture.startLocation.x > 60 { return } let translation = gesture.translation.width // Определяем базовое смещение в зависимости от того, открыто меню или нет let baseOffset = isSideMenuPresented ? menuWidth : 0 // Новое смещение — это база плюс текущий свайп let newOffset = baseOffset + translation // Жестко ограничиваем итоговое смещение между 0 и шириной меню self.menuOffset = max(0, min(menuWidth, newOffset)) } .onEnded { gesture in if !isSideMenuPresented && gesture.startLocation.x > 60 { return } let threshold = menuWidth * 0.4 withAnimation(.easeInOut) { if self.menuOffset > threshold { isSideMenuPresented = true } else { isSideMenuPresented = false } // Устанавливаем финальное смещение после анимации self.menuOffset = isSideMenuPresented ? menuWidth : 0 } } ) } .navigationViewStyle(StackNavigationViewStyle()) .onChange(of: isSideMenuPresented) { presented in // Плавная анимация при нажатии на кнопку, а не только при жесте withAnimation(.easeInOut) { menuOffset = presented ? menuWidth : 0 } } .onReceive(NotificationCenter.default.publisher(for: .socketDidReceivePrivateMessage)) { notification in guard let message = notification.object as? MessageItem else { return } handleIncomingMessage(message) } } } struct MainView_Previews: PreviewProvider { static var previews: some View { let mockViewModel = LoginViewModel() MainView(viewModel: mockViewModel) .environmentObject(ThemeManager()) } } private extension MainView { func handleIncomingMessage(_ message: MessageItem) { guard message.senderId != viewModel.userId else { return } let banner = IncomingMessageBanner( message: message, senderName: senderDisplayName(for: message), messagePreview: messagePreview(for: message) ) bannerDismissWorkItem?.cancel() withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { incomingMessageBanner = banner } let workItem = DispatchWorkItem { withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { incomingMessageBanner = nil } bannerDismissWorkItem = nil } bannerDismissWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: workItem) } func dismissIncomingBanner() { bannerDismissWorkItem?.cancel() bannerDismissWorkItem = nil withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { incomingMessageBanner = nil } } func openChat(from banner: IncomingMessageBanner) { dismissIncomingBanner() pendingChatDeepLink = ChatDeepLink( chatId: banner.message.chatId, chatProfile: banner.message.senderData, message: banner.message ) withAnimation(.easeInOut) { selectedTab = 2 isSideMenuPresented = false menuOffset = 0 } } func senderDisplayName(for message: MessageItem) -> String { let candidates: [String?] = [ message.senderData?.customName, message.senderData?.fullName, message.senderData?.login.map { "@\($0)" } ] for candidate in candidates { if let value = candidate?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty { return value } } return message.senderId } func messagePreview(for message: MessageItem) -> String { if let content = message.content?.trimmingCharacters(in: .whitespacesAndNewlines), !content.isEmpty { return content } switch message.messageType.lowercased() { case "image": return NSLocalizedString("Изображение", comment: "Image message placeholder") case "audio": return NSLocalizedString("Аудио", comment: "Audio message placeholder") case "video": return NSLocalizedString("Видео", comment: "Video message placeholder") default: return NSLocalizedString("Новое сообщение", comment: "Default banner subtitle") } } } struct IncomingMessageBanner: Identifiable { let id = UUID() let message: MessageItem let senderName: String let messagePreview: String } struct NewMessageBannerView: View { let senderName: String let messagePreview: String let onOpen: () -> Void let onDismiss: () -> Void var body: some View { HStack(alignment: .center, spacing: 12) { Image(systemName: "bubble.left.and.bubble.right.fill") .foregroundColor(.accentColor) .imageScale(.medium) VStack(alignment: .leading, spacing: 4) { Text(senderName) .font(.headline) .foregroundColor(.primary) .lineLimit(1) Text(messagePreview) .font(.subheadline) .foregroundColor(.secondary) .lineLimit(2) } Spacer(minLength: 8) Button(action: onDismiss) { Image(systemName: "xmark") .font(.footnote.weight(.semibold)) .foregroundColor(.secondary) .padding(6) .background(Color.secondary.opacity(0.15), in: Circle()) } .buttonStyle(.plain) } .padding(.horizontal, 16) .padding(.vertical, 14) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) .shadow(color: Color.black.opacity(0.12), radius: 12, y: 6) .contentShape(Rectangle()) .onTapGesture(perform: onOpen) } }