Compare commits
10 Commits
ad0577f1fb
...
7a7f2eec5e
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a7f2eec5e | |||
| 2a12b1a00d | |||
| 70115c5bff | |||
| a33222390a | |||
| 6dc496b426 | |||
| 692696b937 | |||
| b14523a136 | |||
| cacb25d34a | |||
| 42e9e4984a | |||
| c22545a3b8 |
113
yobble/Components/ProfileHeaderCardView.swift
Normal file
113
yobble/Components/ProfileHeaderCardView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -320,6 +320,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Ваш рейтинг" : {
|
||||||
|
"comment" : "Messenger settings rating title"
|
||||||
|
},
|
||||||
"Введите код" : {
|
"Введите код" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@ -1600,7 +1603,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Недоступно" : {
|
"Недоступно" : {
|
||||||
"comment" : "Message profile rating unavailable"
|
"comment" : "Message profile rating unavailable\nMessenger settings rating unavailable"
|
||||||
},
|
},
|
||||||
"Неизвестная ошибка" : {
|
"Неизвестная ошибка" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -1633,7 +1636,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Неизвестный пользователь" : {
|
"Неизвестный пользователь" : {
|
||||||
"comment" : "Deleted user display name\nMessage profile fallback title\nUnknown chat title",
|
"comment" : "Deleted user display name\nMessage profile fallback title\nMessenger settings unknown user\nUnknown chat title",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -2079,6 +2082,7 @@
|
|||||||
"comment" : "Поле подтверждения пароля на приложение"
|
"comment" : "Поле подтверждения пароля на приложение"
|
||||||
},
|
},
|
||||||
"Повторить" : {
|
"Повторить" : {
|
||||||
|
"comment" : "Messenger profile header retry",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -2098,7 +2102,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Поддержка iOS 15 работает в экспериментальном режиме. Для лучшей совместимости требуется iOS 16+." : {
|
"Поддержка iOS 15/16 работает в экспериментальном режиме. Для лучшей совместимости требуется iOS 17+." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Поделитесь идеями, сообщите об ошибке или расскажите, что работает отлично." : {
|
"Поделитесь идеями, сообщите об ошибке или расскажите, что работает отлично." : {
|
||||||
@ -2666,6 +2670,9 @@
|
|||||||
},
|
},
|
||||||
"Рожки и ножки у сообщений" : {
|
"Рожки и ножки у сообщений" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"С %@ на Yobble" : {
|
||||||
|
"comment" : "Messenger profile membership"
|
||||||
},
|
},
|
||||||
"Сборка:" : {
|
"Сборка:" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -3220,6 +3227,9 @@
|
|||||||
},
|
},
|
||||||
"Экспериментальная поддержка iOS 15" : {
|
"Экспериментальная поддержка iOS 15" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Экспериментальная поддержка iOS 15/16" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Это устройство" : {
|
"Это устройство" : {
|
||||||
"comment" : "Заголовок секции текущего устройства"
|
"comment" : "Заголовок секции текущего устройства"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -97,7 +97,7 @@ struct LoginView: View {
|
|||||||
|
|
||||||
private var shouldShowLegacySupportNotice: Bool {
|
private var shouldShowLegacySupportNotice: Bool {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
let requiredVersion = OperatingSystemVersion(majorVersion: 16, minorVersion: 0, patchVersion: 0)
|
let requiredVersion = OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0)
|
||||||
return !ProcessInfo.processInfo.isOperatingSystemAtLeast(requiredVersion)
|
return !ProcessInfo.processInfo.isOperatingSystemAtLeast(requiredVersion)
|
||||||
#else
|
#else
|
||||||
return false
|
return false
|
||||||
@ -479,7 +479,7 @@ private struct LegacySupportNoticeView: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
Text("Поддержка iOS 15 работает в экспериментальном режиме. Для лучшей совместимости требуется iOS 16+.")
|
Text("Поддержка iOS 15/16 работает в экспериментальном режиме. Для лучшей совместимости требуется iOS 17+.")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|||||||
@ -18,7 +18,6 @@ struct ChatsTab: View {
|
|||||||
private let chatService = ChatService()
|
private let chatService = ChatService()
|
||||||
@AppStorage("chatRowMessageLineLimit") private var messageLineLimitSetting: Int = 2
|
@AppStorage("chatRowMessageLineLimit") private var messageLineLimitSetting: Int = 2
|
||||||
@StateObject private var viewModel = PrivateChatsViewModel()
|
@StateObject private var viewModel = PrivateChatsViewModel()
|
||||||
@State private var selectedChatId: String?
|
|
||||||
@State private var searchDragStartProgress: CGFloat = 0
|
@State private var searchDragStartProgress: CGFloat = 0
|
||||||
@State private var isSearchGestureActive: Bool = false
|
@State private var isSearchGestureActive: Bool = false
|
||||||
@State private var globalSearchResults: [UserSearchResult] = []
|
@State private var globalSearchResults: [UserSearchResult] = []
|
||||||
@ -447,7 +446,7 @@ struct ChatsTab: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func chatRowItem(for chat: PrivateChatListItem) -> some View {
|
private func chatRowItem(for chat: PrivateChatListItem) -> some View {
|
||||||
Button {
|
Button {
|
||||||
selectedChatId = chat.chatId
|
openChat(chat)
|
||||||
} label: {
|
} label: {
|
||||||
ChatRowView(
|
ChatRowView(
|
||||||
chat: chat,
|
chat: chat,
|
||||||
@ -470,16 +469,6 @@ struct ChatsTab: View {
|
|||||||
Label(NSLocalizedString("Удалить чат (скоро)", comment: ""), systemImage: "trash")
|
Label(NSLocalizedString("Удалить чат (скоро)", comment: ""), systemImage: "trash")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(
|
|
||||||
NavigationLink(
|
|
||||||
destination: PrivateChatView(chat: chat, currentUserId: currentUserId),
|
|
||||||
tag: chat.chatId,
|
|
||||||
selection: $selectedChatId
|
|
||||||
) {
|
|
||||||
EmptyView()
|
|
||||||
}
|
|
||||||
.hidden()
|
|
||||||
)
|
|
||||||
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
||||||
// .listRowSeparator(.hidden)
|
// .listRowSeparator(.hidden)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@ -488,6 +477,12 @@ struct ChatsTab: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private func openChat(_ chat: PrivateChatListItem) {
|
||||||
|
pendingChatItem = chat
|
||||||
|
isPendingChatActive = true
|
||||||
|
}
|
||||||
|
|
||||||
private var globalSearchLoadingRow: some View {
|
private var globalSearchLoadingRow: some View {
|
||||||
HStack {
|
HStack {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
|
|||||||
@ -3,9 +3,18 @@ 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 let bannerRowInsets = EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)
|
||||||
|
private let aboutRowInsets = EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)
|
||||||
|
private let compactSectionSpacing: CGFloat = 6
|
||||||
|
|
||||||
private var selectedThemeOption: ThemeOption {
|
private var selectedThemeOption: ThemeOption {
|
||||||
ThemeOption.option(for: themeManager.theme)
|
ThemeOption.option(for: themeManager.theme)
|
||||||
@ -15,9 +24,14 @@ struct SettingsView: View {
|
|||||||
Form {
|
Form {
|
||||||
if shouldShowLegacySupportBanner {
|
if shouldShowLegacySupportBanner {
|
||||||
LegacySupportBanner()
|
LegacySupportBanner()
|
||||||
.listRowInsets(EdgeInsets(top: 12, leading: 0, bottom: 4, trailing: 0))
|
.listRowInsets(bannerRowInsets)
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isMessengerModeEnabled {
|
||||||
|
messengerProfileHeaderSection
|
||||||
|
aboutSection
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Профиль
|
// MARK: - Профиль
|
||||||
Section(header: Text(NSLocalizedString("Профиль", comment: ""))) {
|
Section(header: Text(NSLocalizedString("Профиль", comment: ""))) {
|
||||||
@ -124,8 +138,14 @@ struct SettingsView: View {
|
|||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Выход
|
// MARK: - Выход
|
||||||
Section {
|
Section (
|
||||||
|
header: Spacer()
|
||||||
|
.frame(height: 32)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
){
|
||||||
Button(action: {
|
Button(action: {
|
||||||
viewModel.logoutCurrentUser()
|
viewModel.logoutCurrentUser()
|
||||||
}) {
|
}) {
|
||||||
@ -138,13 +158,176 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Настройки")
|
.navigationTitle("Настройки")
|
||||||
|
.onAppear {
|
||||||
|
loadMessengerProfileIfNeeded()
|
||||||
|
}
|
||||||
|
.onChange(of: isMessengerModeEnabled) { newValue in
|
||||||
|
if newValue {
|
||||||
|
loadMessengerProfileIfNeeded(force: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.applyFormSectionSpacing(compactSectionSpacing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var messengerProfileHeaderSection: some View {
|
||||||
|
if messengerProfile != nil || isMessengerProfileLoading || messengerProfileError != nil {
|
||||||
|
Section {
|
||||||
|
if let profile = messengerProfile {
|
||||||
|
NavigationLink(destination: EditProfileView()) {
|
||||||
|
ProfileHeaderCardView(
|
||||||
|
avatar: messengerAvatar(for: profile),
|
||||||
|
displayName: messengerDisplayName(for: profile),
|
||||||
|
presenceStatus: nil,
|
||||||
|
statusTags: messengerStatusTags(for: profile),
|
||||||
|
isOfficial: profile.isVerified
|
||||||
|
)
|
||||||
|
.contentShape(Rectangle()) // ← чтобы тап работал по всей площади
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain) // ← чтобы не было system highlight
|
||||||
|
.listRowInsets(bannerRowInsets)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
} else if isMessengerProfileLoading {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.listRowInsets(bannerRowInsets)
|
||||||
|
.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, 12)
|
||||||
|
.listRowInsets(bannerRowInsets)
|
||||||
|
.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
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
|
if profile.isVerified {
|
||||||
|
tags.append(
|
||||||
|
ProfileHeaderCardView.StatusTag(
|
||||||
|
icon: "checkmark.seal.fill",
|
||||||
|
text: NSLocalizedString("Подтверждённый профиль", comment: "Message profile verified tag"),
|
||||||
|
background: Color.white.opacity(0.18),
|
||||||
|
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
|
||||||
|
|
||||||
@ -179,12 +362,129 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
private var shouldShowLegacySupportBanner: Bool {
|
private var shouldShowLegacySupportBanner: Bool {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
let requiredVersion = OperatingSystemVersion(majorVersion: 16, minorVersion: 0, patchVersion: 0)
|
let requiredVersion = OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0)
|
||||||
return !ProcessInfo.processInfo.isOperatingSystemAtLeast(requiredVersion)
|
return !ProcessInfo.processInfo.isOperatingSystemAtLeast(requiredVersion)
|
||||||
#else
|
#else
|
||||||
return false
|
return false
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static let membershipFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .long
|
||||||
|
formatter.timeStyle = .none
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var aboutSection: some View {
|
||||||
|
if let _ = messengerProfile {
|
||||||
|
Section(
|
||||||
|
header: Spacer()
|
||||||
|
.frame(height: 16)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
){
|
||||||
|
card {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
infoRow(
|
||||||
|
title: NSLocalizedString("Юзернейм", comment: ""),
|
||||||
|
value: loginDisplay ?? NSLocalizedString("Неизвестный пользователь", comment: "Messenger settings unknown user")
|
||||||
|
)
|
||||||
|
|
||||||
|
if let membership = membershipDescription {
|
||||||
|
rowDivider
|
||||||
|
infoRow(
|
||||||
|
title: NSLocalizedString("Дата регистрации в Yobble", comment: ""),
|
||||||
|
value: membership
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowDivider
|
||||||
|
infoRow(
|
||||||
|
title: NSLocalizedString("Ваш рейтинг", comment: "Messenger settings rating title"),
|
||||||
|
value: ratingDisplayValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listRowInsets(aboutRowInsets)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func infoRow(icon: String? = nil, title: String, value: String) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
if let icon {
|
||||||
|
iconBackground(color: .accentColor.opacity(0.18)) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(value)
|
||||||
|
.font(.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func iconBackground<Content: View>(color: Color, @ViewBuilder content: () -> Content) -> some View {
|
||||||
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.overlay(content())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func card<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||||||
|
.fill(Color(UIColor.secondarySystemGroupedBackground))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rowDivider: some View {
|
||||||
|
Divider()
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loginDisplay: String? {
|
||||||
|
let login = messengerProfile?.login.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !login.isEmpty else { return nil }
|
||||||
|
return "@\(login)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var membershipDescription: String? {
|
||||||
|
guard let createdAt = messengerProfile?.createdAt else { return nil }
|
||||||
|
let formatted = SettingsView.membershipFormatter.string(from: createdAt)
|
||||||
|
return formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
private var ratingDisplayValue: String {
|
||||||
|
NSLocalizedString("Недоступно", comment: "Messenger settings rating unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension View {
|
||||||
|
@ViewBuilder
|
||||||
|
func applyFormSectionSpacing(_ spacing: CGFloat) -> some View {
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
self.listSectionSpacing(.custom(spacing))
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct LegacySupportBanner: View {
|
private struct LegacySupportBanner: View {
|
||||||
@ -195,9 +495,9 @@ private struct LegacySupportBanner: View {
|
|||||||
.foregroundColor(.yellow)
|
.foregroundColor(.yellow)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Экспериментальная поддержка iOS 15")
|
Text("Экспериментальная поддержка iOS 15/16")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text("Поддержка iOS 15 работает в экспериментальном режиме. Для лучшей совместимости требуется iOS 16+.")
|
Text("Поддержка iOS 15/16 работает в экспериментальном режиме. Для лучшей совместимости требуется iOS 17+.")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user