ios_app_v2/yobble/Views/Tab/ChatsTab.swift
2025-10-07 04:36:21 +03:00

702 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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<CGFloat>) {
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")
}