ios_app_v2/yobble/Views/Tab/ChatsTab.swift
2025-10-06 05:30:05 +03:00

440 lines
14 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
struct ChatsTab: View {
var currentUserId: String? = nil
@StateObject private var viewModel = PrivateChatsViewModel()
var body: some View {
content
.background(Color(UIColor.systemBackground))
.onAppear {
viewModel.loadInitialChats()
}
}
@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 {
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))
}
ForEach(viewModel.chats) { chat in
ChatRowView(chat: chat, currentUserId: currentUserId)
.contentShape(Rectangle())
.onAppear {
viewModel.loadMoreIfNeeded(currentItem: chat)
}
}
if viewModel.isLoadingMore {
loadingMoreRow
}
}
.listStyle(.plain)
}
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 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 name
}
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 {
static var previews: some View {
ChatsTab()
.environmentObject(ThemeManager())
}
}