From 41c25009a154794771c678c8e0ad491877172e50 Mon Sep 17 00:00:00 2001 From: cheykrym Date: Wed, 10 Dec 2025 03:57:14 +0300 Subject: [PATCH] add profile placeholder --- yobble/Resources/Localizable.xcstrings | 9 +- yobble/Views/Chat/PrivateChatView.swift | 222 +++++++++++++++++++++--- 2 files changed, 209 insertions(+), 22 deletions(-) diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index 65779b2..5d644cb 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -1420,7 +1420,7 @@ } }, "Неизвестный пользователь" : { - "comment" : "Deleted user display name\nUnknown chat title", + "comment" : "Deleted user display name\nMessage profile fallback title\nUnknown chat title", "localizations" : { "en" : { "stringUnit" : { @@ -2228,6 +2228,7 @@ "comment" : "Contacts placeholder message" }, "Профиль" : { + "comment" : "Message profile placeholder nav title", "localizations" : { "en" : { "stringUnit" : { @@ -2240,6 +2241,9 @@ "Профиль в разработке" : { "comment" : "Search placeholder title" }, + "Профиль для сообщений пока в разработке." : { + "comment" : "Message profile placeholder title" + }, "Профиль и поиск" : { "localizations" : { "en" : { @@ -2497,6 +2501,9 @@ "Скоро" : { "comment" : "Add blocked user placeholder title\nContacts placeholder title\nЗаголовок заглушки" }, + "Скоро здесь появится информация о собеседнике, статусе и дополнительных действиях." : { + "comment" : "Message profile placeholder description" + }, "Скоро появится мини-игра, где можно заработать очки для кастомизации профиля. Следите за обновлениями!" : { "comment" : "Concept tab placeholder description" }, diff --git a/yobble/Views/Chat/PrivateChatView.swift b/yobble/Views/Chat/PrivateChatView.swift index c221c05..2e9a7e1 100644 --- a/yobble/Views/Chat/PrivateChatView.swift +++ b/yobble/Views/Chat/PrivateChatView.swift @@ -19,6 +19,7 @@ struct PrivateChatView: View { @State private var inputTab: ComposerTab = .chat @State private var isVideoPreferred: Bool = false @State private var legacyComposerHeight: CGFloat = 40 + @State private var isProfilePresented: Bool = false @FocusState private var isComposerFocused: Bool @EnvironmentObject private var messageCenter: IncomingMessageCenter @Environment(\.dismiss) private var dismiss @@ -30,23 +31,33 @@ struct PrivateChatView: View { } var body: some View { - ScrollViewReader { proxy in - ZStack(alignment: .bottomTrailing) { - content - .onChange(of: viewModel.messages.count) { _ in - guard !viewModel.isLoadingMore else { return } - scrollToBottom(proxy: proxy) - } - .onChange(of: scrollToBottomTrigger) { _ in - scrollToBottom(proxy: proxy) - } + ZStack { + ScrollViewReader { proxy in + ZStack(alignment: .bottomTrailing) { + content + .onChange(of: viewModel.messages.count) { _ in + guard !viewModel.isLoadingMore else { return } + scrollToBottom(proxy: proxy) + } + .onChange(of: scrollToBottomTrigger) { _ in + scrollToBottom(proxy: proxy) + } - if !isBottomAnchorVisible { - scrollToBottomButton(proxy: proxy) - .padding(.trailing, 12) - .padding(.bottom, 4) + if !isBottomAnchorVisible { + scrollToBottomButton(proxy: proxy) + .padding(.trailing, 12) + .padding(.bottom, 4) + } } } + + NavigationLink( + destination: MessageProfilePlaceholderView(chat: chat, currentUserId: currentUserId), + isActive: $isProfilePresented + ) { + EmptyView() + } + .hidden() } .navigationTitle(toolbarTitle) .navigationBarTitleDisplayMode(.inline) @@ -573,11 +584,11 @@ struct PrivateChatView: View { Spacer(minLength: 0) - nameStatusView + nameStatusButton Spacer(minLength: 0) - avatarView + avatarButton } .frame(maxWidth: .infinity, minHeight: headerAvatarSize, alignment: .center) } @@ -620,6 +631,20 @@ struct PrivateChatView: View { } } + private var nameStatusButton: some View { + Button(action: openProfile) { + nameStatusView + } + .buttonStyle(.plain) + } + + private var avatarButton: some View { + Button(action: openProfile) { + avatarView + } + .buttonStyle(.plain) + } + private func nameText(_ text: String, weight: Font.Weight) -> some View { Group { if #available(iOS 16.0, *) { @@ -651,25 +676,180 @@ struct PrivateChatView: View { .contentShape(Rectangle()) } - private var headerPlaceholderAvatar: some View { + private func openProfile() { + isProfilePresented = true + } + +private var headerPlaceholderAvatar: some View { + Circle() + .fill(avatarBackgroundColor) + .frame(width: headerAvatarSize, height: headerAvatarSize) + .overlay( + Group { + if isDeletedUser { + Image(systemName: deletedUserSymbolName) + .symbolRenderingMode(.hierarchical) + .font(.system(size: headerAvatarSize * 0.45, weight: .semibold)) + .foregroundColor(avatarTextColor) + } else { + Text(avatarInitial) + .font(.system(size: headerAvatarSize * 0.5, weight: .semibold)) + .foregroundColor(avatarTextColor) + } + } + ) +} +} + +struct MessageProfilePlaceholderView: View { + let chat: PrivateChatListItem + let currentUserId: String? + private let avatarSize: CGFloat = 96 + + var body: some View { + ScrollView { + VStack(spacing: 24) { + profileAvatar + + VStack(spacing: 4) { + Text(displayName) + .font(.title3) + .fontWeight(.semibold) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + + if let login = loginDisplay { + Text(login) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + Text(NSLocalizedString("Профиль для сообщений пока в разработке.", comment: "Message profile placeholder title")) + .font(.body) + .multilineTextAlignment(.center) + + Text(NSLocalizedString("Скоро здесь появится информация о собеседнике, статусе и дополнительных действиях.", comment: "Message profile placeholder description")) + .font(.footnote) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 24) + .padding(.top, 60) + .frame(maxWidth: .infinity) + } + .background(Color(UIColor.systemBackground)) + .navigationTitle(NSLocalizedString("Профиль", comment: "Message profile placeholder nav title")) + .navigationBarTitleDisplayMode(.inline) + } + + private var displayName: String { + if let custom = trimmed(chat.chatData?.customName) { + return custom + } + if let full = trimmed(chat.chatData?.fullName) { + return full + } + if let login = trimmed(chat.chatData?.login) { + return "@\(login)" + } + return NSLocalizedString("Неизвестный пользователь", comment: "Message profile fallback title") + } + + private var loginDisplay: String? { + guard let login = trimmed(chat.chatData?.login) else { return nil } + return "@\(login)" + } + + private var isDeletedUser: Bool { + trimmed(chat.chatData?.login) == nil + } + + private var isOfficial: Bool { + chat.chatData?.isOfficial ?? false + } + + private var avatarBackgroundColor: Color { + if isDeletedUser { + return Color(.systemGray5) + } + return isOfficial ? Color.accentColor.opacity(0.85) : Color.accentColor.opacity(0.15) + } + + private var avatarTextColor: Color { + if isDeletedUser { + return Color.accentColor + } + return isOfficial ? Color.white : Color.accentColor + } + + private var avatarInitial: String { + if let name = trimmed(chat.chatData?.customName) ?? trimmed(chat.chatData?.fullName) { + let components = name.split(separator: " ") + let initials = components.prefix(2).compactMap { $0.first } + if !initials.isEmpty { + return initials.map { String($0) }.joined().uppercased() + } + } + + if let login = trimmed(chat.chatData?.login) { + return String(login.prefix(1)).uppercased() + } + + return "?" + } + + private var avatarUrl: URL? { + guard let chatData = chat.chatData, + let fileId = chatData.avatars?.current?.fileId else { + return nil + } + let userId = chatData.userId + return URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(userId)?file_id=\(fileId)") + } + + @ViewBuilder + private var profileAvatar: some View { + if let url = avatarUrl, + let fileId = chat.chatData?.avatars?.current?.fileId, + let userId = currentUserId { + CachedAvatarView(url: url, fileId: fileId, userId: userId) { + placeholderAvatar + } + .aspectRatio(contentMode: .fill) + .frame(width: avatarSize, height: avatarSize) + .clipShape(Circle()) + } else { + placeholderAvatar + } + } + + private var placeholderAvatar: some View { Circle() .fill(avatarBackgroundColor) - .frame(width: headerAvatarSize, height: headerAvatarSize) + .frame(width: avatarSize, height: avatarSize) .overlay( Group { if isDeletedUser { - Image(systemName: deletedUserSymbolName) + Image(systemName: "person.slash") .symbolRenderingMode(.hierarchical) - .font(.system(size: headerAvatarSize * 0.45, weight: .semibold)) + .font(.system(size: avatarSize * 0.45, weight: .semibold)) .foregroundColor(avatarTextColor) } else { Text(avatarInitial) - .font(.system(size: headerAvatarSize * 0.5, weight: .semibold)) + .font(.system(size: avatarSize * 0.45, weight: .semibold)) .foregroundColor(avatarTextColor) } } ) } + + private func trimmed(_ text: String?) -> String? { + guard let text = text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { + return nil + } + return text + } } #if canImport(UIKit)