284 lines
9.9 KiB
Swift
284 lines
9.9 KiB
Swift
import SwiftUI
|
||
|
||
struct BlockedUsersView: View {
|
||
@State private var blockedUsers: [BlockedUser] = []
|
||
@State private var isLoading = false
|
||
@State private var hasMore = true
|
||
@State private var offset = 0
|
||
@State private var loadError: String?
|
||
@State private var pendingUnblock: BlockedUser?
|
||
@State private var showUnblockConfirmation = false
|
||
@State private var removingUserIds: Set<UUID> = []
|
||
@State private var activeAlert: ActiveAlert?
|
||
@State private var errorMessageDown: String?
|
||
|
||
private let blockedUsersService = BlockedUsersService()
|
||
private let limit = 20
|
||
|
||
var body: some View {
|
||
List {
|
||
if isLoading && blockedUsers.isEmpty {
|
||
initialLoadingState
|
||
} else if let loadError, blockedUsers.isEmpty {
|
||
errorState(loadError)
|
||
} else if blockedUsers.isEmpty {
|
||
emptyState
|
||
} else {
|
||
usersSection
|
||
}
|
||
}
|
||
.navigationTitle(NSLocalizedString("Заблокированные", comment: ""))
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .navigationBarTrailing) {
|
||
Button {
|
||
activeAlert = .addPlaceholder
|
||
} label: {
|
||
Image(systemName: "plus")
|
||
}
|
||
}
|
||
}
|
||
.task {
|
||
await loadBlockedUsers()
|
||
}
|
||
.alert(item: $activeAlert) { alert in
|
||
switch alert {
|
||
case .addPlaceholder:
|
||
return Alert(
|
||
title: Text(NSLocalizedString("Скоро", comment: "Add blocked user placeholder title")),
|
||
message: Text(NSLocalizedString("Добавление новых блокировок появится позже.", comment: "Add blocked user placeholder message")),
|
||
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Common OK")))
|
||
)
|
||
case .error(_, let message):
|
||
return Alert(
|
||
title: Text(NSLocalizedString("Ошибка", comment: "Common error title")),
|
||
message: Text(message),
|
||
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Common OK")))
|
||
)
|
||
}
|
||
}
|
||
.confirmationDialog(
|
||
NSLocalizedString("Удалить из заблокированных?", comment: "Unblock confirmation title"),
|
||
isPresented: $showUnblockConfirmation,
|
||
presenting: pendingUnblock
|
||
) { user in
|
||
Button(NSLocalizedString("Разблокировать", comment: "Unblock confirmation action"), role: .destructive) {
|
||
pendingUnblock = nil
|
||
showUnblockConfirmation = false
|
||
Task {
|
||
await unblock(user)
|
||
}
|
||
}
|
||
Button(NSLocalizedString("Отмена", comment: "Common cancel"), role: .cancel) {
|
||
pendingUnblock = nil
|
||
showUnblockConfirmation = false
|
||
}
|
||
} message: { user in
|
||
Text(String(format: NSLocalizedString("Пользователь \"%1$@\" будет удалён из списка заблокированных.", comment: "Unblock confirmation message"), user.displayName))
|
||
}
|
||
}
|
||
|
||
private var usersSection: some View {
|
||
Section(header: Text(NSLocalizedString("Заблокированные", comment: ""))) {
|
||
ForEach(Array(blockedUsers.enumerated()), id: \.element.id) { index, user in
|
||
userRow(user, index: index)
|
||
}
|
||
if isLoading && !blockedUsers.isEmpty {
|
||
Text("Идет загрузка...")
|
||
.foregroundColor(.gray)
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
.listRowBackground(Color.clear)
|
||
.listRowSeparator(.hidden)
|
||
} else if let errorMessage = errorMessageDown {
|
||
Text(errorMessage)
|
||
.foregroundColor(.red)
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
.listRowBackground(Color.clear)
|
||
.listRowSeparator(.hidden)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func userRow(_ user: BlockedUser, index: Int) -> some View {
|
||
HStack(spacing: 12) {
|
||
Circle()
|
||
.fill(Color.accentColor.opacity(0.15))
|
||
.frame(width: 44, height: 44)
|
||
.overlay(
|
||
Text(user.initials)
|
||
.font(.headline)
|
||
.foregroundColor(.accentColor)
|
||
)
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(user.displayName)
|
||
.font(.body)
|
||
if let handle = user.handle {
|
||
Text(handle)
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
Spacer()
|
||
}
|
||
.padding(.vertical, 0)
|
||
.swipeActions(edge: .trailing) {
|
||
Button(role: .destructive) {
|
||
pendingUnblock = user
|
||
showUnblockConfirmation = true
|
||
} label: {
|
||
Label(NSLocalizedString("Разблокировать", comment: ""), systemImage: "person.crop.circle.badge.xmark")
|
||
}
|
||
.disabled(removingUserIds.contains(user.id))
|
||
}
|
||
.onAppear {
|
||
if index >= blockedUsers.count - 5 {
|
||
Task {
|
||
await loadBlockedUsers()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var emptyState: some View {
|
||
VStack(spacing: 12) {
|
||
Image(systemName: "hand.raised")
|
||
.font(.system(size: 48))
|
||
.foregroundColor(.secondary)
|
||
Text(NSLocalizedString("У вас нет заблокированных пользователей", comment: ""))
|
||
.font(.headline)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
.padding(.vertical, 32)
|
||
.listRowInsets(EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16))
|
||
.listRowSeparator(.hidden)
|
||
}
|
||
|
||
private var initialLoadingState: some View {
|
||
Section {
|
||
ProgressView()
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
}
|
||
}
|
||
|
||
private func errorState(_ message: String) -> some View {
|
||
Section {
|
||
Text(message)
|
||
.foregroundColor(.red)
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func loadBlockedUsers() async {
|
||
errorMessageDown = nil
|
||
guard !isLoading, hasMore else {
|
||
return
|
||
}
|
||
|
||
isLoading = true
|
||
defer { isLoading = false }
|
||
|
||
if offset == 0 {
|
||
loadError = nil
|
||
}
|
||
|
||
do {
|
||
let payload = try await blockedUsersService.fetchBlockedUsers(limit: limit, offset: offset)
|
||
blockedUsers.append(contentsOf: payload.items.map(BlockedUser.init))
|
||
offset += payload.items.count
|
||
hasMore = payload.hasMore
|
||
} catch {
|
||
let message = error.localizedDescription
|
||
if offset == 0 {
|
||
loadError = message
|
||
}
|
||
// activeAlert = .error(message: message)
|
||
errorMessageDown = message
|
||
if AppConfig.DEBUG { print("[BlockedUsersView] load blocked users failed: \(error)") }
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func unblock(_ user: BlockedUser) async {
|
||
guard !removingUserIds.contains(user.id) else { return }
|
||
|
||
removingUserIds.insert(user.id)
|
||
defer { removingUserIds.remove(user.id) }
|
||
|
||
do {
|
||
_ = try await blockedUsersService.remove(userId: user.id)
|
||
blockedUsers.removeAll { $0.id == user.id }
|
||
} catch {
|
||
activeAlert = .error(message: error.localizedDescription)
|
||
if AppConfig.DEBUG { print("[BlockedUsersView] unblock failed: \(error)") }
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct BlockedUser: Identifiable, Equatable {
|
||
let id: UUID
|
||
let login: String
|
||
let fullName: String?
|
||
let customName: String?
|
||
let createdAt: Date
|
||
|
||
private(set) var displayName: String
|
||
private(set) var handle: String?
|
||
|
||
var initials: String {
|
||
let components = displayName.split(separator: " ")
|
||
let nameInitials = components.prefix(2).compactMap { $0.first }
|
||
if !nameInitials.isEmpty {
|
||
return nameInitials
|
||
.map { String($0).uppercased() }
|
||
.joined()
|
||
}
|
||
|
||
if let handle {
|
||
let filtered = handle.filter { $0.isLetter }.prefix(2)
|
||
if !filtered.isEmpty {
|
||
return filtered.uppercased()
|
||
}
|
||
}
|
||
|
||
return "??"
|
||
}
|
||
|
||
init(payload: BlockedUserInfo) {
|
||
self.id = payload.userId
|
||
self.login = payload.login
|
||
self.fullName = payload.fullName
|
||
self.customName = payload.customName
|
||
self.createdAt = payload.createdAt
|
||
|
||
if let customName = payload.customName, !customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
self.displayName = customName
|
||
} else if let fullName = payload.fullName, !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
self.displayName = fullName
|
||
} else {
|
||
self.displayName = payload.login
|
||
}
|
||
|
||
if !payload.login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
self.handle = "@\(payload.login)"
|
||
} else {
|
||
self.handle = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
private enum ActiveAlert: Identifiable {
|
||
case addPlaceholder
|
||
case error(id: UUID = UUID(), message: String)
|
||
|
||
var id: String {
|
||
switch self {
|
||
case .addPlaceholder:
|
||
return "addPlaceholder"
|
||
case .error(let id, _):
|
||
return id.uuidString
|
||
}
|
||
}
|
||
}
|