699 lines
24 KiB
Swift
699 lines
24 KiB
Swift
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) {}
|
||
}
|