// // ChatsTab.swift // VolnahubApp // // Created by cheykrym on 09/06/2025. // import SwiftUI #if canImport(UIKit) import UIKit #endif struct ChatsTab: View { var currentUserId: String? @Binding var searchRevealProgress: CGFloat @StateObject private var viewModel = PrivateChatsViewModel() @State private var selectedChatId: String? @State private var searchText: String = "" @State private var searchDragStartProgress: CGFloat = 0 @State private var isSearchGestureActive: Bool = false private let searchRevealDistance: CGFloat = 90 init(currentUserId: String? = nil, searchRevealProgress: Binding) { self.currentUserId = currentUserId self._searchRevealProgress = searchRevealProgress } var body: some View { content .background(Color(UIColor.systemBackground)) .onAppear { viewModel.loadInitialChats() } .onReceive(NotificationCenter.default.publisher(for: .debugRefreshChats)) { _ in viewModel.refresh() } } @ViewBuilder private var content: some View { if viewModel.isInitialLoading && viewModel.chats.isEmpty { loadingState } else if let message = viewModel.errorMessage, viewModel.chats.isEmpty { errorState(message: message) } else if viewModel.chats.isEmpty { emptyState } else { chatList } } private var chatList: some View { List { // VStack(spacing: 0) { // searchBar // .padding(.horizontal, 16) // .padding(.vertical, 8) // } // .background(Color(UIColor.systemBackground)) if let message = viewModel.errorMessage { Section { HStack(alignment: .top, spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) Text(message) .font(.subheadline) .foregroundColor(.orange) Spacer(minLength: 0) Button(action: { viewModel.refresh() }) { Text(NSLocalizedString("Обновить", comment: "")) .font(.subheadline) } } .padding(.vertical, 4) } .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) } if filteredChats.isEmpty && isSearching { Section { emptySearchResultView } .listRowInsets(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16)) .listRowSeparator(.hidden) } else { ForEach(filteredChats) { chat in Button { selectedChatId = chat.chatId } label: { ChatRowView(chat: chat, currentUserId: currentUserId) .contentShape(Rectangle()) } .buttonStyle(.plain) .contextMenu { Button(action: {}) { Label(NSLocalizedString("Закрепить (скоро)", comment: ""), systemImage: "pin") } Button(action: {}) { Label(NSLocalizedString("Без звука (скоро)", comment: ""), systemImage: "speaker.slash") } Button(role: .destructive, action: {}) { Label(NSLocalizedString("Удалить чат (скоро)", comment: ""), systemImage: "trash") } } .background( NavigationLink( destination: ChatPlaceholderView(chat: chat), tag: chat.chatId, selection: $selectedChatId ) { EmptyView() } .hidden() ) .onAppear { guard !isSearching else { return } viewModel.loadMoreIfNeeded(currentItem: chat) } } } if viewModel.isLoadingMore && !isSearching { loadingMoreRow } } .listStyle(.plain) .modifier(ScrollDismissesKeyboardModifier()) .simultaneousGesture(searchBarGesture) .simultaneousGesture(tapToDismissKeyboardGesture) // .safeAreaInset(edge: .top) { // VStack(spacing: 0) { // searchBar // .padding(.horizontal, 16) // .padding(.top, 8) // .padding(.bottom, 8) // Divider() // } // .background(Color(UIColor.systemBackground)) // } } private var searchBar: some View { HStack(spacing: 8) { Image(systemName: "magnifyingglass") .foregroundColor(.secondary) TextField(NSLocalizedString("Поиск", comment: ""), text: $searchText) .textFieldStyle(.plain) .textInputAutocapitalization(.never) .autocorrectionDisabled() if !searchText.isEmpty { Button(action: { searchText = "" }) { Image(systemName: "xmark.circle.fill") .foregroundColor(.secondary) } .buttonStyle(.plain) } } .padding(.horizontal, 12) .padding(.vertical, 6) .frame(minHeight: 36) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color(UIColor.secondarySystemBackground)) ) } private var searchBarGesture: some Gesture { DragGesture(minimumDistance: 10, coordinateSpace: .local) .onChanged { value in let verticalTranslation = value.translation.height let horizontalTranslation = value.translation.width if !isSearchGestureActive { guard abs(verticalTranslation) > abs(horizontalTranslation) else { return } if searchRevealProgress <= 0.001 && verticalTranslation < 0 { return } isSearchGestureActive = true searchDragStartProgress = searchRevealProgress } guard isSearchGestureActive else { return } let delta = verticalTranslation / searchRevealDistance let newProgress = searchDragStartProgress + delta searchRevealProgress = max(0, min(1, newProgress)) } .onEnded { _ in guard isSearchGestureActive else { return } isSearchGestureActive = false let target: CGFloat = searchRevealProgress > 0.5 ? 1 : 0 withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { searchRevealProgress = target } } } private var tapToDismissKeyboardGesture: some Gesture { TapGesture().onEnded { dismissKeyboard() } } private var isSearching: Bool { !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } private var filteredChats: [PrivateChatListItem] { let trimmedQuery = searchText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedQuery.isEmpty else { return viewModel.chats } let lowercasedQuery = trimmedQuery.lowercased() return viewModel.chats.filter { chat in let searchableValues = [ chat.chatData?.customName, chat.chatData?.fullName, chat.chatData?.login, chat.lastMessage?.content ] return searchableValues .compactMap { $0?.lowercased() } .contains(where: { $0.contains(lowercasedQuery) }) } } private var emptySearchResultView: some View { VStack(spacing: 8) { Image(systemName: "text.magnifyingglass") .font(.system(size: 40)) .foregroundColor(.secondary) Text(NSLocalizedString("Ничего не найдено", comment: "")) .font(.headline) Text(NSLocalizedString("Попробуйте изменить запрос поиска.", comment: "")) .font(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) } private var loadingState: some View { VStack(spacing: 12) { ProgressView() Text(NSLocalizedString("Загружаем чаты…", comment: "")) .font(.subheadline) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } private func errorState(message: String) -> some View { VStack(spacing: 12) { Image(systemName: "exclamationmark.bubble") .font(.system(size: 48)) .foregroundColor(.orange) Text(message) .font(.body) .multilineTextAlignment(.center) .foregroundColor(.primary) Button(action: { viewModel.loadInitialChats(force: true) }) { Text(NSLocalizedString("Повторить", comment: "")) .font(.headline) } .buttonStyle(.borderedProminent) } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) } private var emptyState: some View { VStack(spacing: 12) { Image(systemName: "bubble.left") .font(.system(size: 48)) .foregroundColor(.secondary) Text(NSLocalizedString("Пока что у вас нет чатов", comment: "")) .font(.body) .foregroundColor(.secondary) Button(action: { viewModel.refresh() }) { Text(NSLocalizedString("Обновить", comment: "")) } .buttonStyle(.bordered) } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) } private var loadingMoreRow: some View { HStack { Spacer() ProgressView() .padding(.vertical, 12) Spacer() } .listRowSeparator(.hidden) } } private extension ChatsTab { func dismissKeyboard() { #if canImport(UIKit) UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) #endif } } private struct ScrollDismissesKeyboardModifier: ViewModifier { func body(content: Content) -> some View { if #available(iOS 16.0, *) { content.scrollDismissesKeyboard(.interactively) } else { content } } } private struct ChatRowView: View { let chat: PrivateChatListItem let currentUserId: String? private var title: String { switch chat.chatType { case .self: return NSLocalizedString("Избранные сообщения", comment: "") case .privateChat, .unknown: if let custom = chat.chatData?.customName, !custom.isEmpty { return custom } if let full = chat.chatData?.fullName, !full.isEmpty { return full } if let login = chat.chatData?.login, !login.isEmpty { return "@\(login)" } return NSLocalizedString("Неизвестный пользователь", comment: "") } } private var officialFullName: String? { guard let name = chat.chatData?.fullName?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty else { return nil } return NSLocalizedString(name, comment: "") } private var loginDisplay: String? { guard let login = chat.chatData?.login?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty else { return nil } return "@\(login)" } private var isOfficial: Bool { officialFullName != nil } private var avatarBackgroundColor: Color { isOfficial ? Color.accentColor.opacity(0.85) : Color.accentColor.opacity(0.15) } private var avatarTextColor: Color { isOfficial ? Color.white : Color.accentColor } private var messagePreview: String { guard let message = chat.lastMessage else { return NSLocalizedString("Нет сообщений", comment: "") } let body = messageBody(for: message) guard let prefix = authorPrefix(for: message) else { return body } return body.isEmpty ? prefix : "\(body)" // return body.isEmpty ? prefix : "\(prefix): \(body)" } private func messageBody(for message: MessageItem) -> String { if let content = trimmed(message.content), !content.isEmpty { return content } if message.mediaLink != nil { return NSLocalizedString("Вложение", comment: "") } return NSLocalizedString("Сообщение", comment: "") } private func authorPrefix(for message: MessageItem) -> String? { let isCurrentUser = currentUserId.map { $0 == message.senderId } ?? false if isCurrentUser { return NSLocalizedString("Вы", comment: "") } let profile = message.senderData ?? chat.chatData return displayName(for: profile) } private func displayName(for profile: ChatProfile?) -> String? { guard let profile else { return nil } let login = trimmed(profile.login) let customName = trimmed(profile.customName) let fullName = trimmed(profile.fullName) let isOfficialProfile = fullName != nil if isOfficialProfile, let login { if let customName { return "@\(login) (\(customName))" } return "@\(login)" } if let customName { return customName } if let login { return "@\(login)" } return fullName } private func trimmed(_ string: String?) -> String? { guard let string = string?.trimmingCharacters(in: .whitespacesAndNewlines), !string.isEmpty else { return nil } return string } private var timestamp: String? { let date = chat.lastMessage?.createdAt ?? chat.createdAt guard let date else { return nil } return ChatRowView.formattedTimestamp(for: date) } private var initial: String { let sourceName: String if let full = officialFullName { sourceName = full } else if let custom = chat.chatData?.customName, !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { sourceName = custom } else if let login = chat.chatData?.login?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty { sourceName = login } else { sourceName = NSLocalizedString("Неизвестный пользователь", comment: "") } if let character = sourceName.first(where: { !$0.isWhitespace && $0 != "@" }) { return String(character).uppercased() } return "?" } private var subtitleColor: Color { chat.unreadCount > 0 ? .primary : .secondary } private var shouldShowReadStatus: Bool { guard let message = chat.lastMessage else { return false } let isCurrentUser = currentUserId.map { $0 == message.senderId } ?? false return isCurrentUser } private var readStatusIconName: String { guard let message = chat.lastMessage else { return "" } return message.isViewed == true ? "checkmark.circle.fill" : "checkmark.circle" } private var readStatusColor: Color { guard let message = chat.lastMessage else { return .secondary } return message.isViewed == true ? Color.accentColor : Color.secondary } var body: some View { HStack(spacing: 12) { Circle() .fill(avatarBackgroundColor) .frame(width: 44, height: 44) .overlay( Text(initial) .font(.headline) .foregroundColor(avatarTextColor) ) VStack(alignment: .leading, spacing: 4) { if let officialName = officialFullName { HStack(spacing: 6) { Text(officialName) .fontWeight(.semibold) .foregroundColor(.primary) .lineLimit(1) .truncationMode(.tail) Image(systemName: "checkmark.seal.fill") .foregroundColor(Color.accentColor) .font(.caption) } // if let login = loginDisplay { // Text(login) // .font(.footnote) // .foregroundColor(.secondary) // .lineLimit(1) // .truncationMode(.tail) // } } else { Text(title) .fontWeight(chat.unreadCount > 0 ? .semibold : .regular) .foregroundColor(.primary) .lineLimit(1) .truncationMode(.tail) } Text(messagePreview) .font(.subheadline) .foregroundColor(subtitleColor) .lineLimit(2) .truncationMode(.tail) } .frame(maxWidth: .infinity, alignment: .leading) Spacer() VStack(alignment: .trailing, spacing: 6) { if let timestamp { HStack(spacing: 4) { if shouldShowReadStatus { Image(systemName: readStatusIconName) .foregroundColor(readStatusColor) .font(.caption2) } Text(timestamp) .font(.caption) .foregroundColor(.secondary) } } if chat.unreadCount > 0 { Text("\(chat.unreadCount)") .font(.caption2.bold()) .foregroundColor(.white) .padding(.horizontal, 8) .padding(.vertical, 4) .background( Capsule().fill(Color.accentColor) ) } } } .padding(.vertical, 8) } private static func formattedTimestamp(for date: Date) -> String { let calendar = Calendar.current let locale = Locale.current let now = Date() let startOfNow = calendar.startOfDay(for: now) let startOfDate = calendar.startOfDay(for: date) let diff = calendar.dateComponents([.day], from: startOfDate, to: startOfNow).day ?? 0 if diff <= 0 { return timeString(for: date, locale: locale) } if diff == 1 { return locale.identifier.lowercased().hasPrefix("ru") ? "Вчера" : "Yesterday" } if diff < 7 { return weekdayFormatter(for: locale).string(from: date) } if diff < 365 { return monthDayFormatter(for: locale).string(from: date) } return fullDateFormatter(for: locale).string(from: date) } private static func timeString(for date: Date, locale: Locale) -> String { let timeFormat = DateFormatter.dateFormat(fromTemplate: "j", options: 0, locale: locale) ?? "" let is12Hour = timeFormat.contains("a") || timeFormat.contains("h") || timeFormat.contains("K") let formatter = DateFormatter() formatter.locale = locale if is12Hour { formatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "h:mm a", options: 0, locale: locale) ?? "h:mm a" } else { formatter.dateFormat = "HH:mm" } return formatter.string(from: date) } private static func weekdayFormatter(for locale: Locale) -> DateFormatter { let formatter = DateFormatter() formatter.locale = locale formatter.setLocalizedDateFormatFromTemplate("EEE") return formatter } private static func monthDayFormatter(for locale: Locale) -> DateFormatter { let formatter = DateFormatter() formatter.locale = locale formatter.setLocalizedDateFormatFromTemplate("MMM d") return formatter } private static func fullDateFormatter(for locale: Locale) -> DateFormatter { let formatter = DateFormatter() formatter.locale = locale formatter.dateFormat = "dd.MM.yy" return formatter } } struct ChatsTab_Previews: PreviewProvider { struct Wrapper: View { @State private var progress: CGFloat = 1 var body: some View { ChatsTab(searchRevealProgress: $progress) .environmentObject(ThemeManager()) } } static var previews: some View { Wrapper() } } private struct ChatPlaceholderView: View { let chat: PrivateChatListItem private var companionId: String { if let profileId = chat.chatData?.userId { return profileId } if let senderId = chat.lastMessage?.senderId { return senderId } return NSLocalizedString("Неизвестный", comment: "") } var body: some View { VStack(spacing: 16) { Text(NSLocalizedString("Экран чата в разработке", comment: "")) .font(.headline) VStack(alignment: .leading, spacing: 8) { HStack { Text("Chat ID:") .font(.subheadline) .foregroundColor(.secondary) Text(chat.chatId) .font(.body.monospaced()) } if companionId != NSLocalizedString("Неизвестный", comment: "") { HStack { Text("Companion ID:") .font(.subheadline) .foregroundColor(.secondary) Text(companionId) .font(.body.monospaced()) } } } .frame(maxWidth: .infinity, alignment: .leading) Spacer() } .padding() .navigationTitle(title) .navigationBarTitleDisplayMode(.inline) } private var title: String { if let full = chat.chatData?.fullName, !full.isEmpty { return full } if let custom = chat.chatData?.customName, !custom.isEmpty { return custom } if let login = chat.chatData?.login, !login.isEmpty { return "@\(login)" } return NSLocalizedString("Чат", comment: "") } } extension Notification.Name { static let debugRefreshChats = Notification.Name("debugRefreshChats") }