diff --git a/yobble/Components/TopBarView.swift b/yobble/Components/TopBarView.swift index 2175323..e057c84 100644 --- a/yobble/Components/TopBarView.swift +++ b/yobble/Components/TopBarView.swift @@ -17,6 +17,10 @@ struct TopBarView: View { return title == "Home" } + var isChatsTab: Bool { + return title == "Chats" + } + var isProfileTab: Bool { return title == "Profile" } @@ -81,6 +85,15 @@ struct TopBarView: View { .foregroundColor(.primary) } } + } else if isChatsTab { + // Кнопка поиска + Button(action: { + // пока ничего не делаем + }) { + Image(systemName: "magnifyingglass") + .imageScale(.large) + .foregroundColor(.primary) + } } else if isProfileTab { NavigationLink(destination: SettingsView(viewModel: viewModel)) { Image(systemName: "wrench") diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index 8cfdadf..cabc4b4 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -98,6 +98,9 @@ } } } + }, + "Вы" : { + }, "Вы предложили: %@" : { diff --git a/yobble/Views/Tab/ChatsTab.swift b/yobble/Views/Tab/ChatsTab.swift index 112895a..398aa7e 100644 --- a/yobble/Views/Tab/ChatsTab.swift +++ b/yobble/Views/Tab/ChatsTab.swift @@ -8,6 +8,7 @@ import SwiftUI struct ChatsTab: View { + var currentUserId: String? = nil @StateObject private var viewModel = PrivateChatsViewModel() var body: some View { @@ -53,7 +54,7 @@ struct ChatsTab: View { } ForEach(viewModel.chats) { chat in - ChatRowView(chat: chat) + ChatRowView(chat: chat, currentUserId: currentUserId) .contentShape(Rectangle()) .onAppear { viewModel.loadMoreIfNeeded(currentItem: chat) @@ -126,6 +127,7 @@ struct ChatsTab: View { private struct ChatRowView: View { let chat: PrivateChatListItem + let currentUserId: String? private var title: String { switch chat.chatType { @@ -145,12 +147,47 @@ private struct ChatRowView: View { } } - private var subtitle: String { + 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: "") } - if let content = message.content, !content.isEmpty { + 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 } @@ -161,50 +198,158 @@ private struct ChatRowView: View { 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.timeFormatter.string(from: date) + return ChatRowView.formattedTimestamp(for: date) } private var initial: String { - return String(title.prefix(1)).uppercased() + 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(Color.accentColor.opacity(0.15)) + .fill(avatarBackgroundColor) .frame(width: 44, height: 44) .overlay( Text(initial) .font(.headline) - .foregroundColor(Color.accentColor) + .foregroundColor(avatarTextColor) ) VStack(alignment: .leading, spacing: 4) { - Text(title) - .fontWeight(chat.unreadCount > 0 ? .semibold : .regular) - .foregroundColor(.primary) - .lineLimit(1) + if let officialName = officialFullName { + HStack(spacing: 6) { + Text(officialName) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) + .truncationMode(.tail) - Text(subtitle) + 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 { - Text(timestamp) - .font(.caption) - .foregroundColor(.secondary) + HStack(spacing: 4) { + if shouldShowReadStatus { + Image(systemName: readStatusIconName) + .foregroundColor(readStatusColor) + .font(.caption2) + } + + Text(timestamp) + .font(.caption) + .foregroundColor(.secondary) + } } if chat.unreadCount > 0 { @@ -222,12 +367,68 @@ private struct ChatRowView: View { .padding(.vertical, 8) } - private static let timeFormatter: DateFormatter = { + 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.dateStyle = .short - formatter.timeStyle = .short + 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 { diff --git a/yobble/Views/Tab/MainView.swift b/yobble/Views/Tab/MainView.swift index dfda7db..6db0581 100644 --- a/yobble/Views/Tab/MainView.swift +++ b/yobble/Views/Tab/MainView.swift @@ -48,7 +48,7 @@ struct MainView: View { FeedbackTab() .opacity(selectedTab == 1 ? 1 : 0) - ChatsTab() + ChatsTab(currentUserId: viewModel.userId.isEmpty ? nil : viewModel.userId) .opacity(selectedTab == 2 ? 1 : 0) ProfileTab()