ios_app_v2/yobble/Views/Tab/Settings/EditProfileView.swift
2025-12-10 03:18:32 +03:00

699 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
struct EditProfileView: View {
// State for form fields
@State private var displayName = ""
@State private var description = ""
@State private var originalDisplayName = ""
@State private var originalDescription = ""
// State for profile data and avatar
@State private var profile: ProfileDataPayload?
@State private var avatarImage: UIImage?
@State private var showImagePicker = false
// State for loading and errors
@State private var isLoading = false
@State private var isSaving = false
@State private var isUploadingAvatar = false
@State private var isPreparingDownload = false
@State private var alertMessage: String?
@State private var showAlert = false
@State private var avatarViewerState: AvatarViewerState?
@State private var shareItems: [Any] = []
@State private var showShareSheet = false
private let profileService = ProfileService()
private let descriptionLimit = 1024
var body: some View {
ZStack {
Form {
Section {
HStack {
Spacer()
VStack {
if let image = avatarImage {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: 120, height: 120)
.clipShape(Circle())
.contentShape(Rectangle())
.onTapGesture {
presentAvatarViewer()
}
} else if let profile = profile,
let fileId = profile.avatars?.current?.fileId,
let url = avatarUrl(for: profile, fileId: fileId) {
CachedAvatarView(url: url, fileId: fileId, userId: profile.userId.uuidString) {
avatarPlaceholder
}
.aspectRatio(contentMode: .fill)
.frame(width: 120, height: 120)
.clipShape(Circle())
.contentShape(Rectangle())
.onTapGesture {
presentAvatarViewer()
}
} else {
avatarPlaceholder
.onTapGesture {
presentAvatarViewer()
}
}
Button("Изменить фото") {
showImagePicker = true
}
.padding(.top, 8)
}
Spacer()
}
}
.listRowBackground(Color(UIColor.systemGroupedBackground))
Section(header: Text("Публичная информация")) {
TextField("Отображаемое имя", text: $displayName)
VStack(alignment: .leading, spacing: 5) {
Text("Описание")
.font(.caption)
.foregroundColor(.secondary)
TextEditor(text: $description)
.frame(height: 150)
.onChange(of: description) { newValue in
if newValue.count > descriptionLimit {
description = String(newValue.prefix(descriptionLimit))
}
}
HStack {
Spacer()
Text("\(description.count) / \(descriptionLimit)")
.font(.caption)
.foregroundColor(description.count > descriptionLimit ? .red : .secondary)
}
}
}
Button(action: {
Task {
await applyProfileChanges()
}
}) {
if isSaving {
ProgressView()
.frame(maxWidth: .infinity, alignment: .center)
} else {
Text("Применить")
.frame(maxWidth: .infinity, alignment: .center)
}
}
.disabled(!hasProfileChanges || isBusy)
}
.navigationTitle("Профиль")
.onAppear(perform: loadProfile)
.sheet(isPresented: $showImagePicker) {
ImagePicker(image: $avatarImage, allowsEditing: true)
}
.onChange(of: avatarImage) { newValue in
guard let image = newValue else { return }
Task {
await uploadAvatarImage(image)
}
}
.alert("Ошибка", isPresented: $showAlert, presenting: alertMessage) { _ in
Button("OK") {}
} message: { message in
Text(message)
}
.fullScreenCover(item: $avatarViewerState) { state in
AvatarViewerView(
state: state,
onClose: { avatarViewerState = nil },
onDownload: { handleAvatarDownload(for: state) },
onDelete: handleAvatarDeletion
)
}
.sheet(isPresented: $showShareSheet) {
ActivityView(activityItems: shareItems)
}
if isBusy {
Color.black.opacity(0.4).ignoresSafeArea()
ProgressView(busyMessage)
.padding()
.background(Color.secondary.colorInvert())
.cornerRadius(10)
}
}
}
private var avatarPlaceholder: some View {
Circle()
.fill(Color.secondary.opacity(0.2))
.frame(width: 120, height: 120)
.overlay(
Image(systemName: "person.fill")
.font(.system(size: 60))
.foregroundColor(.gray)
)
}
private func avatarUrl(for profile: ProfileDataPayload, fileId: String) -> URL? {
return URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(profile.userId)?file_id=\(fileId)")
}
private func loadProfile() {
isLoading = true
Task {
do {
let profile = try await profileService.fetchMyProfile()
await MainActor.run {
self.updateForm(with: profile)
self.isLoading = false
}
} catch {
await MainActor.run {
self.alertMessage = error.localizedDescription
self.showAlert = true
self.isLoading = false
}
}
}
}
private var hasProfileChanges: Bool {
displayName != originalDisplayName || description != originalDescription
}
private var isBusy: Bool {
isLoading || isSaving || isUploadingAvatar || isPreparingDownload
}
private var busyMessage: String {
if isUploadingAvatar {
return "Обновление аватара..."
}
if isPreparingDownload {
return "Подготовка изображения..."
}
if isSaving {
return "Сохранение..."
}
return "Загрузка..."
}
@MainActor
private func applyProfileChanges() async {
guard !isSaving else { return }
guard let currentProfile = profile else {
alertMessage = NSLocalizedString("Профиль пока не загружен. Попробуйте позже.", comment: "Profile not ready error")
showAlert = true
return
}
isSaving = true
let request = ProfileUpdateRequestPayload(
fullName: displayName,
bio: description,
profilePermissions: ProfilePermissionsRequestPayload(payload: currentProfile.profilePermissions)
)
do {
_ = try await profileService.updateProfile(request)
let refreshedProfile = try await profileService.fetchMyProfile()
updateForm(with: refreshedProfile)
} catch {
let message: String
if let error = error as? LocalizedError, let description = error.errorDescription {
message = description
} else {
message = error.localizedDescription
}
alertMessage = message
showAlert = true
}
isSaving = false
}
@MainActor
private func uploadAvatarImage(_ image: UIImage) async {
guard !isUploadingAvatar else { return }
isUploadingAvatar = true
defer { isUploadingAvatar = false }
do {
_ = try await profileService.uploadAvatar(image: image)
let refreshedProfile = try await profileService.fetchMyProfile()
updateFormPreservingFields(profile: refreshedProfile)
avatarImage = nil
} catch {
let message: String
if let error = error as? LocalizedError, let description = error.errorDescription {
message = description
} else {
message = error.localizedDescription
}
alertMessage = message
showAlert = true
}
}
@MainActor
private func updateForm(with profile: ProfileDataPayload) {
self.profile = profile
applyProfileTexts(from: profile)
}
@MainActor
private func updateFormPreservingFields(profile: ProfileDataPayload) {
self.profile = profile
if !hasProfileChanges {
applyProfileTexts(from: profile)
}
}
@MainActor
private func applyProfileTexts(from profile: ProfileDataPayload) {
let loadedName = profile.fullName ?? ""
let loadedBio = profile.bio ?? ""
self.displayName = loadedName
self.description = loadedBio
self.originalDisplayName = loadedName
self.originalDescription = loadedBio
}
private func presentAvatarViewer() {
if let image = avatarImage {
avatarViewerState = AvatarViewerState(
source: .local(image),
intrinsicSize: image.size
)
return
}
guard let profile,
let fileId = profile.avatars?.current?.fileId,
let url = avatarUrl(for: profile, fileId: fileId) else { return }
let intrinsicSize: CGSize?
if let width = profile.avatars?.current?.width,
let height = profile.avatars?.current?.height,
width > 0,
height > 0 {
intrinsicSize = CGSize(width: CGFloat(width), height: CGFloat(height))
} else {
intrinsicSize = nil
}
avatarViewerState = AvatarViewerState(
source: .remote(url: url, fileId: fileId, userId: profile.userId.uuidString),
intrinsicSize: intrinsicSize
)
}
private func handleAvatarDownload(for state: AvatarViewerState) {
guard !isPreparingDownload else { return }
isPreparingDownload = true
Task {
do {
let image = try await resolveImage(for: state)
await MainActor.run {
shareItems = [image]
showShareSheet = true
}
} catch {
await MainActor.run {
alertMessage = error.localizedDescription
showAlert = true
}
}
await MainActor.run {
isPreparingDownload = false
}
}
}
private func handleAvatarDeletion() {
alertMessage = NSLocalizedString("Удаление аватара пока недоступно.", comment: "Avatar delete placeholder")
showAlert = true
}
private func resolveImage(for state: AvatarViewerState) async throws -> UIImage {
switch state.source {
case .local(let image):
return image
case .remote(let url, let fileId, let userId):
if let cached = AvatarCacheService.shared.getImage(forKey: fileId, userId: userId) {
return cached
}
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw AvatarViewerError.imageDecodingFailed
}
AvatarCacheService.shared.saveImage(image, forKey: fileId, userId: userId)
return image
}
}
}
struct ImagePicker: UIViewControllerRepresentable {
@Binding var image: UIImage?
var allowsEditing: Bool = false
@Environment(\.presentationMode) private var presentationMode
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.allowsEditing = allowsEditing
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
let parent: ImagePicker
init(_ parent: ImagePicker) {
self.parent = parent
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let editedImage = info[.editedImage] as? UIImage {
parent.image = editedImage
} else if let uiImage = info[.originalImage] as? UIImage {
parent.image = uiImage
}
parent.presentationMode.wrappedValue.dismiss()
}
}
}
struct AvatarViewerState: Identifiable {
enum Source {
case local(UIImage)
case remote(url: URL, fileId: String, userId: String)
}
let id = UUID()
let source: Source
let intrinsicSize: CGSize?
}
enum AvatarViewerError: LocalizedError {
case imageDecodingFailed
var errorDescription: String? {
switch self {
case .imageDecodingFailed:
return NSLocalizedString("Не удалось подготовить изображение.", comment: "Avatar decoding error")
}
}
}
struct AvatarViewerView: View {
let state: AvatarViewerState
let onClose: () -> Void
let onDownload: () -> Void
let onDelete: () -> Void
@State private var scale: CGFloat = 1.0
@State private var baseScale: CGFloat = 1.0
@State private var panOffset: CGSize = .zero
@State private var storedPanOffset: CGSize = .zero
@State private var dismissOffset: CGSize = .zero
@State private var dragMode: DragMode?
@State private var containerSize: CGSize = .zero
@State private var loadedImageSize: CGSize?
private enum DragMode {
case vertical
case horizontal
}
private var currentOffset: CGSize {
scale > 1.05 ? panOffset : dismissOffset
}
private var dragProgress: CGFloat {
guard scale <= 1.05 else { return 0 }
let progress = min(1, abs(dismissOffset.height) / 220)
return progress
}
private var backgroundOpacity: Double {
Double(1 - dragProgress * 0.6)
}
private var overlayOpacity: Double {
Double(1 - dragProgress * 0.8)
}
private var effectiveImageSize: CGSize? {
loadedImageSize ?? state.intrinsicSize
}
var body: some View {
ZStack {
Color.black.opacity(backgroundOpacity).ignoresSafeArea()
zoomableContent
topOverlay
.opacity(overlayOpacity)
}
}
private var topOverlay: some View {
VStack(spacing: 8) {
HStack {
Button(action: onClose) {
Image(systemName: "xmark")
.imageScale(.large)
}
.tint(.white)
Spacer()
Text("1 из 1")
.font(.subheadline.weight(.semibold))
.foregroundColor(.white.opacity(0.8))
Spacer()
Menu {
Button(action: onDownload) {
Label(NSLocalizedString("Скачать", comment: "Avatar download"), systemImage: "square.and.arrow.down")
}
Button(role: .destructive, action: onDelete) {
Label(NSLocalizedString("Удалить фото", comment: "Avatar delete"), systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
.imageScale(.large)
}
.tint(.white)
}
.padding(.horizontal)
.padding(.top, 24)
Spacer()
}
}
@ViewBuilder
private var zoomableContent: some View {
GeometryReader { proxy in
let size = proxy.size
Color.clear
.onAppear { containerSize = size }
.onChange(of: size) { newValue in
containerSize = newValue
}
.overlay {
content(for: size)
}
}
}
@ViewBuilder
private func content(for size: CGSize) -> some View {
switch state.source {
case .local(let image):
zoomableImage(Image(uiImage: image))
.onAppear {
loadedImageSize = image.size
}
case .remote(let url, let fileId, let userId):
RemoteZoomableImage(url: url, fileId: fileId, userId: userId) { uiImage in
loadedImageSize = uiImage.size
}
.offset(currentOffset)
.scaleEffect(scale, anchor: .center)
.gesture(dragGesture)
.simultaneousGesture(magnificationGesture)
}
}
private func zoomableImage(_ image: Image) -> some View {
image
.resizable()
.scaledToFit()
.offset(currentOffset)
.scaleEffect(scale, anchor: .center)
.gesture(dragGesture)
.simultaneousGesture(magnificationGesture)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var dragGesture: some Gesture {
DragGesture()
.onChanged { value in
if scale > 1.05 {
dismissOffset = .zero
let adjustedTranslation = CGSize(
width: value.translation.width / scale,
height: value.translation.height / scale
)
panOffset = CGSize(
width: storedPanOffset.width + adjustedTranslation.width,
height: storedPanOffset.height + adjustedTranslation.height
)
panOffset = clampedOffset(panOffset)
} else {
if dragMode == nil {
if abs(value.translation.height) > abs(value.translation.width) {
dragMode = .vertical
} else {
dragMode = .horizontal
}
}
switch dragMode {
case .horizontal:
let limitedWidth = min(max(value.translation.width, -80), 80)
dismissOffset = CGSize(width: limitedWidth, height: 0)
case .vertical, .none:
dismissOffset = CGSize(width: 0, height: value.translation.height)
}
}
}
.onEnded { value in
if scale > 1.05 {
let adjustedTranslation = CGSize(
width: value.translation.width / scale,
height: value.translation.height / scale
)
var newOffset = CGSize(
width: storedPanOffset.width + adjustedTranslation.width,
height: storedPanOffset.height + adjustedTranslation.height
)
newOffset = clampedOffset(newOffset)
storedPanOffset = newOffset
} else {
if abs(value.translation.height) > 120 {
onClose()
} else {
withAnimation(.spring()) {
dismissOffset = .zero
}
}
dragMode = nil
}
}
}
private var magnificationGesture: some Gesture {
MagnificationGesture()
.onChanged { value in
let newScale = baseScale * value
scale = min(max(newScale, 1), 4)
}
.onEnded { _ in
baseScale = scale
if baseScale <= 1.02 {
baseScale = 1
withAnimation(.spring()) {
scale = 1
storedPanOffset = .zero
panOffset = .zero
dismissOffset = .zero
}
}
}
}
private func clampedOffset(_ offset: CGSize) -> CGSize {
guard scale > 1.01,
containerSize != .zero else { return offset }
let fittedSize = fittedContentSize(in: containerSize)
let scaledWidth = fittedSize.width * scale
let scaledHeight = fittedSize.height * scale
let maxX = max(0, (scaledWidth - containerSize.width) / 2)
let maxY = max(0, (scaledHeight - containerSize.height) / 2)
let clampedX = max(-maxX, min(offset.width, maxX))
let clampedY = max(-maxY, min(offset.height, maxY))
return CGSize(width: clampedX, height: clampedY)
}
private func fittedContentSize(in container: CGSize) -> CGSize {
guard let imageSize = effectiveImageSize,
imageSize.width > 0,
imageSize.height > 0 else {
return container
}
let widthRatio = container.width / imageSize.width
let heightRatio = container.height / imageSize.height
let ratio = min(widthRatio, heightRatio)
return CGSize(width: imageSize.width * ratio, height: imageSize.height * ratio)
}
}
private struct RemoteZoomableImage: View {
@StateObject private var loader: ImageLoader
let onImageLoaded: (UIImage) -> Void
init(url: URL, fileId: String, userId: String, onImageLoaded: @escaping (UIImage) -> Void) {
_loader = StateObject(wrappedValue: ImageLoader(url: url, fileId: fileId, userId: userId))
self.onImageLoaded = onImageLoaded
}
var body: some View {
Group {
if let image = loader.image {
Image(uiImage: image)
.resizable()
.scaledToFit()
.onAppear {
onImageLoaded(image)
}
} else {
ProgressView()
.progressViewStyle(.circular)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear(perform: loader.load)
}
}
struct ActivityView: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}