From 13a1d00934dc2fde82786c77fea8aa38b8202304 Mon Sep 17 00:00:00 2001 From: cheykrym Date: Wed, 10 Dec 2025 02:49:56 +0300 Subject: [PATCH] add view img --- yobble/Resources/Localizable.xcstrings | 12 ++ .../Views/Tab/Settings/EditProfileView.swift | 193 +++++++++++++++++- 2 files changed, 204 insertions(+), 1 deletion(-) diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index 4be01d5..7eed751 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -1282,6 +1282,9 @@ "Не удалось подготовить изображение для загрузки." : { "comment" : "Avatar encoding error" }, + "Не удалось подготовить изображение." : { + "comment" : "Avatar decoding error" + }, "Не удалось сериализовать данные запроса." : { "localizations" : { "en" : { @@ -2470,6 +2473,9 @@ } } }, + "Скачать" : { + "comment" : "Avatar download" + }, "Скопировано" : { "comment" : "Заголовок уведомления о копировании" }, @@ -2711,6 +2717,9 @@ } } }, + "Удаление аватара пока недоступно." : { + "comment" : "Avatar delete placeholder" + }, "Удаление контакта \"%1$@\" появится позже." : { "comment" : "Contacts delete placeholder message" }, @@ -2720,6 +2729,9 @@ "Удалить контакт" : { "comment" : "Contacts context action delete" }, + "Удалить фото" : { + "comment" : "Avatar delete" + }, "Удалить чат (скоро)" : { "localizations" : { "en" : { diff --git a/yobble/Views/Tab/Settings/EditProfileView.swift b/yobble/Views/Tab/Settings/EditProfileView.swift index 1262011..1e6ec66 100644 --- a/yobble/Views/Tab/Settings/EditProfileView.swift +++ b/yobble/Views/Tab/Settings/EditProfileView.swift @@ -16,8 +16,12 @@ struct EditProfileView: View { @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 @@ -35,6 +39,10 @@ struct EditProfileView: View { .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) { @@ -44,8 +52,15 @@ struct EditProfileView: View { .aspectRatio(contentMode: .fill) .frame(width: 120, height: 120) .clipShape(Circle()) + .contentShape(Rectangle()) + .onTapGesture { + presentAvatarViewer() + } } else { avatarPlaceholder + .onTapGesture { + presentAvatarViewer() + } } Button("Изменить фото") { @@ -112,6 +127,17 @@ struct EditProfileView: View { } 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() @@ -162,13 +188,16 @@ struct EditProfileView: View { } private var isBusy: Bool { - isLoading || isSaving || isUploadingAvatar + isLoading || isSaving || isUploadingAvatar || isPreparingDownload } private var busyMessage: String { if isUploadingAvatar { return "Обновление аватара..." } + if isPreparingDownload { + return "Подготовка изображения..." + } if isSaving { return "Сохранение..." } @@ -256,6 +285,64 @@ struct EditProfileView: View { self.originalDisplayName = loadedName self.originalDescription = loadedBio } + + private func presentAvatarViewer() { + if let image = avatarImage { + avatarViewerState = AvatarViewerState(source: .local(image)) + return + } + + guard let profile, + let fileId = profile.avatars?.current?.fileId, + let url = avatarUrl(for: profile, fileId: fileId) else { return } + avatarViewerState = AvatarViewerState(source: .remote(url: url, fileId: fileId, userId: profile.userId.uuidString)) + } + + 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 { @@ -293,3 +380,107 @@ struct ImagePicker: UIViewControllerRepresentable { } } } + +struct AvatarViewerState: Identifiable { + enum Source { + case local(UIImage) + case remote(url: URL, fileId: String, userId: String) + } + + let id = UUID() + let source: Source +} + +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 + + var body: some View { + if #available(iOS 16.0, *) { + NavigationStack { + ZStack { + Color.black.ignoresSafeArea() + avatarContent + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: onClose) { + Image(systemName: "xmark") + } + .tint(.white) + } + ToolbarItem(placement: .navigationBarTrailing) { + 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) + } + } + } + } + } + + @ViewBuilder + private var avatarContent: some View { + switch state.source { + case .local(let image): + Image(uiImage: image) + .resizable() + .scaledToFit() + .padding() + case .remote(let url, _, _): + AsyncImage(url: url) { phase in + switch phase { + case .empty: + ProgressView() + .progressViewStyle(.circular) + case .success(let image): + image + .resizable() + .scaledToFit() + .padding() + case .failure: + Image(systemName: "person.crop.circle.badge.exclam") + .resizable() + .scaledToFit() + .frame(width: 120, height: 120) + .foregroundColor(.white.opacity(0.7)) + @unknown default: + EmptyView() + } + } + } + } +} + +struct ActivityView: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +}