update settings

This commit is contained in:
cheykrym 2025-12-13 00:53:11 +03:00
parent c22545a3b8
commit 42e9e4984a
5 changed files with 307 additions and 120 deletions

View File

@ -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: View>(
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)
}
}

View File

@ -8,6 +8,7 @@ struct ProfileDataPayload: Decodable {
let avatars: Avatars? let avatars: Avatars?
let balances: [WalletBalancePayload] let balances: [WalletBalancePayload]
let createdAt: Date? let createdAt: Date?
let isVerified: Bool
let stories: [JSONValue] let stories: [JSONValue]
let profilePermissions: ProfilePermissionsPayload let profilePermissions: ProfilePermissionsPayload
@ -19,6 +20,7 @@ struct ProfileDataPayload: Decodable {
case avatars case avatars
case balances case balances
case createdAt case createdAt
case isVerified
case stories case stories
case profilePermissions case profilePermissions
} }
@ -32,6 +34,7 @@ struct ProfileDataPayload: Decodable {
self.avatars = try container.decodeIfPresent(Avatars.self, forKey: .avatars) self.avatars = try container.decodeIfPresent(Avatars.self, forKey: .avatars)
self.balances = try container.decodeIfPresent([WalletBalancePayload].self, forKey: .balances) ?? [] self.balances = try container.decodeIfPresent([WalletBalancePayload].self, forKey: .balances) ?? []
self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) 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.stories = try container.decodeIfPresent([JSONValue].self, forKey: .stories) ?? []
self.profilePermissions = try container.decode(ProfilePermissionsPayload.self, forKey: .profilePermissions) self.profilePermissions = try container.decode(ProfilePermissionsPayload.self, forKey: .profilePermissions)
} }

View File

@ -2079,6 +2079,7 @@
"comment" : "Поле подтверждения пароля на приложение" "comment" : "Поле подтверждения пароля на приложение"
}, },
"Повторить" : { "Повторить" : {
"comment" : "Messenger profile header retry",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -2666,6 +2667,9 @@
}, },
"Рожки и ножки у сообщений" : { "Рожки и ножки у сообщений" : {
},
"С %@ на Yobble" : {
"comment" : "Messenger profile membership"
}, },
"Сборка:" : { "Сборка:" : {
"localizations" : { "localizations" : {

View File

@ -30,7 +30,13 @@ struct MessageProfileView: View {
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(spacing: 24) { VStack(spacing: 24) {
headerCard ProfileHeaderCardView(
avatar: profileAvatar,
displayName: displayName,
presenceStatus: presenceStatus,
statusTags: statusTags,
isOfficial: isOfficial
)
DescriptionSection DescriptionSection
quickActionsSection quickActionsSection
aboutSection 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 { private var DescriptionSection: some View {
Group { 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 // MARK: - Sections
private var quickActionsSection: some View { 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 { if isDeletedUser {
return PresenceStatus( return HeaderPresenceStatus(
text: NSLocalizedString("Пользователь удалён", comment: "Message profile deleted user status"), text: NSLocalizedString("Пользователь удалён", comment: "Message profile deleted user status"),
isOnline: false isOnline: false
) )
@ -809,14 +725,14 @@ struct MessageProfileView: View {
let interval = Date().timeIntervalSince(lastSeenDate) let interval = Date().timeIntervalSince(lastSeenDate)
if interval < 5 * 60 { if interval < 5 * 60 {
return PresenceStatus( return HeaderPresenceStatus(
text: NSLocalizedString("в сети", comment: "Message profile online status"), text: NSLocalizedString("в сети", comment: "Message profile online status"),
isOnline: true isOnline: true
) )
} }
let relative = MessageProfileView.relativeFormatter.localizedString(for: lastSeenDate, relativeTo: Date()) let relative = MessageProfileView.relativeFormatter.localizedString(for: lastSeenDate, relativeTo: Date())
return PresenceStatus( return HeaderPresenceStatus(
text: String( text: String(
format: NSLocalizedString("был(а) %@", comment: "Message profile last seen relative format"), format: NSLocalizedString("был(а) %@", comment: "Message profile last seen relative format"),
relative relative
@ -825,12 +741,12 @@ struct MessageProfileView: View {
) )
} }
private var statusTags: [StatusTag] { private var statusTags: [HeaderStatusTag] {
var tags: [StatusTag] = [] var tags: [HeaderStatusTag] = []
if isOfficial { if isOfficial {
tags.append( tags.append(
StatusTag( HeaderStatusTag(
icon: "checkmark.seal.fill", icon: "checkmark.seal.fill",
text: NSLocalizedString("Подтверждённый профиль", comment: "Message profile verified tag"), text: NSLocalizedString("Подтверждённый профиль", comment: "Message profile verified tag"),
background: Color.white.opacity(0.18), background: Color.white.opacity(0.18),
@ -842,7 +758,7 @@ struct MessageProfileView: View {
if let relationship = currentChatProfile?.relationship { if let relationship = currentChatProfile?.relationship {
if relationship.isTargetInContactsOfCurrentUser { if relationship.isTargetInContactsOfCurrentUser {
tags.append( tags.append(
StatusTag( HeaderStatusTag(
icon: "person.crop.circle.badge.checkmark", icon: "person.crop.circle.badge.checkmark",
text: NSLocalizedString("В ваших контактах", comment: "Message profile user in contacts tag"), text: NSLocalizedString("В ваших контактах", comment: "Message profile user in contacts tag"),
background: Color.white.opacity(0.14), background: Color.white.opacity(0.14),
@ -853,7 +769,7 @@ struct MessageProfileView: View {
if relationship.isCurrentUserInContactsOfTarget { if relationship.isCurrentUserInContactsOfTarget {
tags.append( tags.append(
StatusTag( HeaderStatusTag(
icon: "person.2.fill", icon: "person.2.fill",
text: NSLocalizedString("Вы в его контактах", comment: "Message profile contact tag"), text: NSLocalizedString("Вы в его контактах", comment: "Message profile contact tag"),
background: Color.white.opacity(0.14), background: Color.white.opacity(0.14),
@ -864,7 +780,7 @@ struct MessageProfileView: View {
if relationship.isCurrentUserInBlacklistOfTarget { if relationship.isCurrentUserInBlacklistOfTarget {
tags.append( tags.append(
StatusTag( HeaderStatusTag(
icon: "hand.thumbsdown.fill", icon: "hand.thumbsdown.fill",
text: NSLocalizedString("Вы в его чёрном списке", comment: "Message profile blacklist tag"), text: NSLocalizedString("Вы в его чёрном списке", comment: "Message profile blacklist tag"),
background: Color.red.opacity(0.4), background: Color.red.opacity(0.4),
@ -875,7 +791,7 @@ struct MessageProfileView: View {
if relationship.isTargetUserBlockedByCurrentUser { if relationship.isTargetUserBlockedByCurrentUser {
tags.append( tags.append(
StatusTag( HeaderStatusTag(
icon: "hand.raised.slash.fill", icon: "hand.raised.slash.fill",
text: NSLocalizedString("Заблокирован", comment: "Message profile blocked tag"), text: NSLocalizedString("Заблокирован", comment: "Message profile blocked tag"),
background: Color.orange.opacity(0.4), background: Color.orange.opacity(0.4),
@ -887,7 +803,7 @@ struct MessageProfileView: View {
if isDeletedUser { if isDeletedUser {
tags.append( tags.append(
StatusTag( HeaderStatusTag(
icon: "person.crop.circle.badge.xmark", icon: "person.crop.circle.badge.xmark",
text: NSLocalizedString("Аккаунт удалён", comment: "Message profile deleted tag"), text: NSLocalizedString("Аккаунт удалён", comment: "Message profile deleted tag"),
background: Color.white.opacity(0.12), 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 { private struct MediaCategory: Identifiable {
let id = UUID() let id = UUID()
let title: String let title: String

View File

@ -3,9 +3,15 @@ import SwiftUI
struct SettingsView: View { struct SettingsView: View {
@ObservedObject var viewModel: LoginViewModel @ObservedObject var viewModel: LoginViewModel
@EnvironmentObject private var themeManager: ThemeManager @EnvironmentObject private var themeManager: ThemeManager
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
@State private var isThemeExpanded = false @State private var isThemeExpanded = false
@State private var isSecurityActive = 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 themeOptions = ThemeOption.ordered
private let profileService = ProfileService()
private let messengerAvatarSize: CGFloat = 96
private var selectedThemeOption: ThemeOption { private var selectedThemeOption: ThemeOption {
ThemeOption.option(for: themeManager.theme) ThemeOption.option(for: themeManager.theme)
@ -18,6 +24,10 @@ struct SettingsView: View {
.listRowInsets(EdgeInsets(top: 12, leading: 0, bottom: 4, trailing: 0)) .listRowInsets(EdgeInsets(top: 12, leading: 0, bottom: 4, trailing: 0))
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
} }
if isMessengerModeEnabled {
messengerProfileHeaderSection
}
// MARK: - Профиль // MARK: - Профиль
Section(header: Text(NSLocalizedString("Профиль", comment: ""))) { Section(header: Text(NSLocalizedString("Профиль", comment: ""))) {
@ -138,13 +148,160 @@ struct SettingsView: View {
} }
} }
.navigationTitle("Настройки") .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() { private func openLanguageSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return } guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url) 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 { private func themeRow(for option: ThemeOption) -> some View {
let isSelected = option == selectedThemeOption let isSelected = option == selectedThemeOption
@ -185,6 +342,13 @@ struct SettingsView: View {
return false return false
#endif #endif
} }
private static let membershipFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .none
return formatter
}()
} }
private struct LegacySupportBanner: View { private struct LegacySupportBanner: View {