diff --git a/yobble/Components/ProfileHeaderCardView.swift b/yobble/Components/ProfileHeaderCardView.swift new file mode 100644 index 0000000..dea50d1 --- /dev/null +++ b/yobble/Components/ProfileHeaderCardView.swift @@ -0,0 +1,113 @@ +import SwiftUI + +struct ProfileHeaderCardView: View { + struct PresenceStatus { + let text: String + let isOnline: Bool + } + + struct StatusTag: Identifiable { + let id = UUID() + let icon: String + let text: String + let background: Color + let tint: Color + } + + private let avatar: AnyView + private let displayName: String + private let presenceStatus: PresenceStatus? + private let statusTags: [StatusTag] + private let isOfficial: Bool + + init( + avatar: Avatar, + displayName: String, + presenceStatus: PresenceStatus?, + statusTags: [StatusTag], + isOfficial: Bool + ) { + self.avatar = AnyView(avatar) + self.displayName = displayName + self.presenceStatus = presenceStatus + self.statusTags = statusTags + self.isOfficial = isOfficial + } + + var body: some View { + VStack(spacing: 16) { + avatar + .overlay(alignment: .bottomTrailing) { + officialBadge + } + + VStack(spacing: 6) { + Text(displayName) + .font(.title2) + .fontWeight(.semibold) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + + if let presenceStatus { + HStack(spacing: 6) { + Circle() + .fill(presenceStatus.isOnline ? Color.green : Color.gray.opacity(0.4)) + .frame(width: 8, height: 8) + Text(presenceStatus.text) + .font(.footnote) + .foregroundColor(.secondary) + } + } + } + + if !statusTags.isEmpty { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 8)], spacing: 8) { + ForEach(statusTags) { tag in + HStack(spacing: 6) { + Image(systemName: tag.icon) + .font(.system(size: 12, weight: .semibold)) + Text(tag.text) + .font(.caption) + .fontWeight(.medium) + } + .padding(.vertical, 6) + .padding(.horizontal, 10) + .foregroundColor(tag.tint) + .background(tag.background) + .clipShape(Capsule()) + } + } + } + } + .padding(24) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 32, style: .continuous) + .fill(headerGradient) + ) + .overlay( + RoundedRectangle(cornerRadius: 32, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.08), radius: 20, x: 0, y: 10) + } + + @ViewBuilder + private var officialBadge: some View { + if isOfficial { + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.white) + .padding(6) + .background(Circle().fill(Color.accentColor)) + .offset(x: 6, y: 6) + } + } + + private var headerGradient: LinearGradient { + let first = isOfficial ? Color.accentColor : Color.accentColor.opacity(0.6) + let second = Color.accentColor.opacity(isOfficial ? 0.6 : 0.3) + let third = Color(UIColor.secondarySystemBackground) + return LinearGradient(colors: [first, second, third], startPoint: .topLeading, endPoint: .bottomTrailing) + } +} diff --git a/yobble/Network/ProfileModels.swift b/yobble/Network/ProfileModels.swift index 994e72f..71f20c4 100644 --- a/yobble/Network/ProfileModels.swift +++ b/yobble/Network/ProfileModels.swift @@ -8,6 +8,7 @@ struct ProfileDataPayload: Decodable { let avatars: Avatars? let balances: [WalletBalancePayload] let createdAt: Date? + let isVerified: Bool let stories: [JSONValue] let profilePermissions: ProfilePermissionsPayload @@ -19,6 +20,7 @@ struct ProfileDataPayload: Decodable { case avatars case balances case createdAt + case isVerified case stories case profilePermissions } @@ -32,6 +34,7 @@ struct ProfileDataPayload: Decodable { self.avatars = try container.decodeIfPresent(Avatars.self, forKey: .avatars) self.balances = try container.decodeIfPresent([WalletBalancePayload].self, forKey: .balances) ?? [] self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) + self.isVerified = try container.decode(Bool.self, forKey: .isVerified) self.stories = try container.decodeIfPresent([JSONValue].self, forKey: .stories) ?? [] self.profilePermissions = try container.decode(ProfilePermissionsPayload.self, forKey: .profilePermissions) } diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index abe9a5c..0a9b5fb 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -2079,6 +2079,7 @@ "comment" : "Поле подтверждения пароля на приложение" }, "Повторить" : { + "comment" : "Messenger profile header retry", "localizations" : { "en" : { "stringUnit" : { @@ -2666,6 +2667,9 @@ }, "Рожки и ножки у сообщений" : { + }, + "С %@ на Yobble" : { + "comment" : "Messenger profile membership" }, "Сборка:" : { "localizations" : { diff --git a/yobble/Views/Chat/MessageProfileView.swift b/yobble/Views/Chat/MessageProfileView.swift index d945e7f..7533f8f 100644 --- a/yobble/Views/Chat/MessageProfileView.swift +++ b/yobble/Views/Chat/MessageProfileView.swift @@ -30,7 +30,13 @@ struct MessageProfileView: View { var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: 24) { - headerCard + ProfileHeaderCardView( + avatar: profileAvatar, + displayName: displayName, + presenceStatus: presenceStatus, + statusTags: statusTags, + isOfficial: isOfficial + ) DescriptionSection quickActionsSection aboutSection @@ -77,80 +83,6 @@ struct MessageProfileView: View { } } - // MARK: - Header - - private var headerCard: some View { - VStack(spacing: 16) { - profileAvatar - .overlay(alignment: .bottomTrailing) { - officialBadge - } - - VStack(spacing: 6) { - HStack(spacing: 6) { - Text(displayName) - .font(.title2) - .fontWeight(.semibold) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - } - -// if let login = loginDisplay { -// Text(login) -// .font(.subheadline) -// .foregroundColor(.secondary) -// } - - if let status = presenceStatus { - HStack(spacing: 6) { - Circle() - .fill(status.isOnline ? Color.green : Color.gray.opacity(0.4)) - .frame(width: 8, height: 8) - Text(status.text) - .font(.footnote) - .foregroundColor(.secondary) - } - } - } - -// if let bio = profileBio { -// Text(bio) -// .font(.body) -// .multilineTextAlignment(.center) -// } - - if !statusTags.isEmpty { - LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 8)], spacing: 8) { - ForEach(statusTags) { tag in - HStack(spacing: 6) { - Image(systemName: tag.icon) - .font(.system(size: 12, weight: .semibold)) - Text(tag.text) - .font(.caption) - .fontWeight(.medium) - } - .padding(.vertical, 6) - .padding(.horizontal, 10) - .foregroundColor(tag.tint) - .background(tag.background) - .clipShape(Capsule()) - } - } - } - } - .padding(24) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 32, style: .continuous) - .fill(headerGradient) - ) - .overlay( - RoundedRectangle(cornerRadius: 32, style: .continuous) - .stroke(Color.white.opacity(0.08), lineWidth: 1) - ) - .shadow(color: Color.black.opacity(0.08), radius: 20, x: 0, y: 10) - } - private var DescriptionSection: some View { Group { @@ -203,25 +135,6 @@ struct MessageProfileView: View { } } - @ViewBuilder - private var officialBadge: some View { - if isOfficial { - Image(systemName: "checkmark.seal.fill") - .font(.system(size: 18, weight: .semibold)) - .foregroundColor(.white) - .padding(6) - .background(Circle().fill(Color.accentColor)) - .offset(x: 6, y: 6) - } - } - - private var headerGradient: LinearGradient { - let first = isOfficial ? Color.accentColor : Color.accentColor.opacity(0.6) - let second = Color.accentColor.opacity(isOfficial ? 0.6 : 0.3) - let third = Color(UIColor.secondarySystemBackground) - return LinearGradient(colors: [first, second, third], startPoint: .topLeading, endPoint: .bottomTrailing) - } - // MARK: - Sections private var quickActionsSection: some View { @@ -796,9 +709,12 @@ struct MessageProfileView: View { } } - private var presenceStatus: PresenceStatus? { + private typealias HeaderPresenceStatus = ProfileHeaderCardView.PresenceStatus + private typealias HeaderStatusTag = ProfileHeaderCardView.StatusTag + + private var presenceStatus: HeaderPresenceStatus? { if isDeletedUser { - return PresenceStatus( + return HeaderPresenceStatus( text: NSLocalizedString("Пользователь удалён", comment: "Message profile deleted user status"), isOnline: false ) @@ -809,14 +725,14 @@ struct MessageProfileView: View { let interval = Date().timeIntervalSince(lastSeenDate) if interval < 5 * 60 { - return PresenceStatus( + return HeaderPresenceStatus( text: NSLocalizedString("в сети", comment: "Message profile online status"), isOnline: true ) } let relative = MessageProfileView.relativeFormatter.localizedString(for: lastSeenDate, relativeTo: Date()) - return PresenceStatus( + return HeaderPresenceStatus( text: String( format: NSLocalizedString("был(а) %@", comment: "Message profile last seen relative format"), relative @@ -825,12 +741,12 @@ struct MessageProfileView: View { ) } - private var statusTags: [StatusTag] { - var tags: [StatusTag] = [] + private var statusTags: [HeaderStatusTag] { + var tags: [HeaderStatusTag] = [] if isOfficial { tags.append( - StatusTag( + HeaderStatusTag( icon: "checkmark.seal.fill", text: NSLocalizedString("Подтверждённый профиль", comment: "Message profile verified tag"), background: Color.white.opacity(0.18), @@ -842,7 +758,7 @@ struct MessageProfileView: View { if let relationship = currentChatProfile?.relationship { if relationship.isTargetInContactsOfCurrentUser { tags.append( - StatusTag( + HeaderStatusTag( icon: "person.crop.circle.badge.checkmark", text: NSLocalizedString("В ваших контактах", comment: "Message profile user in contacts tag"), background: Color.white.opacity(0.14), @@ -853,7 +769,7 @@ struct MessageProfileView: View { if relationship.isCurrentUserInContactsOfTarget { tags.append( - StatusTag( + HeaderStatusTag( icon: "person.2.fill", text: NSLocalizedString("Вы в его контактах", comment: "Message profile contact tag"), background: Color.white.opacity(0.14), @@ -864,7 +780,7 @@ struct MessageProfileView: View { if relationship.isCurrentUserInBlacklistOfTarget { tags.append( - StatusTag( + HeaderStatusTag( icon: "hand.thumbsdown.fill", text: NSLocalizedString("Вы в его чёрном списке", comment: "Message profile blacklist tag"), background: Color.red.opacity(0.4), @@ -875,7 +791,7 @@ struct MessageProfileView: View { if relationship.isTargetUserBlockedByCurrentUser { tags.append( - StatusTag( + HeaderStatusTag( icon: "hand.raised.slash.fill", text: NSLocalizedString("Заблокирован", comment: "Message profile blocked tag"), background: Color.orange.opacity(0.4), @@ -887,7 +803,7 @@ struct MessageProfileView: View { if isDeletedUser { tags.append( - StatusTag( + HeaderStatusTag( icon: "person.crop.circle.badge.xmark", text: NSLocalizedString("Аккаунт удалён", comment: "Message profile deleted tag"), background: Color.white.opacity(0.12), @@ -1129,19 +1045,6 @@ private extension String { } } -private struct PresenceStatus { - let text: String - let isOnline: Bool -} - -private struct StatusTag: Identifiable { - let id = UUID() - let icon: String - let text: String - let background: Color - let tint: Color -} - private struct MediaCategory: Identifiable { let id = UUID() let title: String diff --git a/yobble/Views/Tab/Settings/SettingsView.swift b/yobble/Views/Tab/Settings/SettingsView.swift index 7e84107..8191efb 100644 --- a/yobble/Views/Tab/Settings/SettingsView.swift +++ b/yobble/Views/Tab/Settings/SettingsView.swift @@ -3,9 +3,15 @@ import SwiftUI struct SettingsView: View { @ObservedObject var viewModel: LoginViewModel @EnvironmentObject private var themeManager: ThemeManager + @AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false @State private var isThemeExpanded = false @State private var isSecurityActive = false + @State private var messengerProfile: ProfileDataPayload? + @State private var isMessengerProfileLoading = false + @State private var messengerProfileError: String? private let themeOptions = ThemeOption.ordered + private let profileService = ProfileService() + private let messengerAvatarSize: CGFloat = 96 private var selectedThemeOption: ThemeOption { ThemeOption.option(for: themeManager.theme) @@ -18,6 +24,10 @@ struct SettingsView: View { .listRowInsets(EdgeInsets(top: 12, leading: 0, bottom: 4, trailing: 0)) .listRowBackground(Color.clear) } + + if isMessengerModeEnabled { + messengerProfileHeaderSection + } // MARK: - Профиль Section(header: Text(NSLocalizedString("Профиль", comment: ""))) { @@ -138,13 +148,160 @@ struct SettingsView: View { } } .navigationTitle("Настройки") + .onAppear { + loadMessengerProfileIfNeeded() + } + .onChange(of: isMessengerModeEnabled) { newValue in + if newValue { + loadMessengerProfileIfNeeded(force: true) + } + } } - + + @ViewBuilder + private var messengerProfileHeaderSection: some View { + if messengerProfile != nil || isMessengerProfileLoading || messengerProfileError != nil { + Section { + if let profile = messengerProfile { + ProfileHeaderCardView( + avatar: messengerAvatar(for: profile), + displayName: messengerDisplayName(for: profile), + presenceStatus: nil, + statusTags: [], + isOfficial: self.messengerProfile?.isVerified ?? false + ) + .listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 8, trailing: 0)) + .listRowBackground(Color.clear) + } else if isMessengerProfileLoading { + HStack { + Spacer() + ProgressView() + Spacer() + } + .padding(.vertical, 24) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) + } else if let error = messengerProfileError { + VStack(spacing: 8) { + Text(error) + .font(.footnote) + .multilineTextAlignment(.center) + Button(NSLocalizedString("Повторить", comment: "Messenger profile header retry")) { + loadMessengerProfileIfNeeded(force: true) + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) + } + } + } + } + private func openLanguageSettings() { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) } + private func loadMessengerProfileIfNeeded(force: Bool = false) { + guard isMessengerModeEnabled else { return } + if isMessengerProfileLoading { return } + if !force, messengerProfile != nil { return } + + isMessengerProfileLoading = true + messengerProfileError = nil + + Task { + do { + let profile = try await profileService.fetchMyProfile() + await MainActor.run { + messengerProfile = profile + isMessengerProfileLoading = false + } + } catch { + await MainActor.run { + messengerProfileError = error.localizedDescription + isMessengerProfileLoading = false + } + } + } + } + + @ViewBuilder + private func messengerAvatar(for profile: ProfileDataPayload) -> some View { + if let fileId = profile.avatars?.current?.fileId, + let url = URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(profile.userId.uuidString)?file_id=\(fileId)") { + CachedAvatarView(url: url, fileId: fileId, userId: profile.userId.uuidString) { + messengerAvatarPlaceholder(for: profile) + } + .aspectRatio(contentMode: .fill) + .frame(width: messengerAvatarSize, height: messengerAvatarSize) + .clipShape(Circle()) + } else { + messengerAvatarPlaceholder(for: profile) + } + } + + private func messengerAvatarPlaceholder(for profile: ProfileDataPayload) -> some View { + Circle() + .fill(Color.accentColor.opacity(0.15)) + .frame(width: messengerAvatarSize, height: messengerAvatarSize) + .overlay( + Text(messengerInitials(for: profile)) + .font(.system(size: messengerAvatarSize * 0.45, weight: .semibold)) + .foregroundColor(Color.accentColor) + ) + } + + private func messengerInitials(for profile: ProfileDataPayload) -> String { + if let name = profile.fullName?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { + let components = name.split(separator: " ") + let initials = components.prefix(2).compactMap { $0.first } + if !initials.isEmpty { + return initials.map { String($0) }.joined().uppercased() + } + } + return String(profile.login.prefix(1)).uppercased() + } + + private func messengerDisplayName(for profile: ProfileDataPayload) -> String { + if let name = profile.fullName?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { + return name + } + return "@\(profile.login)" + } + +// private func messengerStatusTags(for profile: ProfileDataPayload) -> [ProfileHeaderCardView.StatusTag] { +// var tags: [ProfileHeaderCardView.StatusTag] = [] +// tags.append( +// ProfileHeaderCardView.StatusTag( +// icon: "at", +// text: "@\(profile.login)", +// background: Color.white.opacity(0.18), +// tint: .white +// ) +// ) +// +// if let createdAt = profile.createdAt { +// let formatted = SettingsView.membershipFormatter.string(from: createdAt) +// tags.append( +// ProfileHeaderCardView.StatusTag( +// icon: "calendar", +// text: String( +// format: NSLocalizedString("С %@ на Yobble", comment: "Messenger profile membership"), +// formatted +// ), +// background: Color.white.opacity(0.12), +// tint: .white +// ) +// ) +// } +// +// return tags +// } + private func themeRow(for option: ThemeOption) -> some View { let isSelected = option == selectedThemeOption @@ -185,6 +342,13 @@ struct SettingsView: View { return false #endif } + + private static let membershipFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .long + formatter.timeStyle = .none + return formatter + }() } private struct LegacySupportBanner: View {