Compare commits
7 Commits
f1ec7e637b
...
9013549362
| Author | SHA1 | Date | |
|---|---|---|---|
| 9013549362 | |||
| 947a504f08 | |||
| 11a5cba8fa | |||
| e4b3a33ebc | |||
| f3653c5617 | |||
| 13a1d00934 | |||
| 134ba39914 |
@ -76,6 +76,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"1 из 1" : {
|
||||
|
||||
},
|
||||
"2FA включена" : {
|
||||
"comment" : "Заголовок уведомления об успешной активации 2FA"
|
||||
@ -1282,6 +1285,9 @@
|
||||
"Не удалось подготовить изображение для загрузки." : {
|
||||
"comment" : "Avatar encoding error"
|
||||
},
|
||||
"Не удалось подготовить изображение." : {
|
||||
"comment" : "Avatar decoding error"
|
||||
},
|
||||
"Не удалось сериализовать данные запроса." : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -2470,6 +2476,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Скачать" : {
|
||||
"comment" : "Avatar download"
|
||||
},
|
||||
"Скопировано" : {
|
||||
"comment" : "Заголовок уведомления о копировании"
|
||||
},
|
||||
@ -2711,6 +2720,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Удаление аватара пока недоступно." : {
|
||||
"comment" : "Avatar delete placeholder"
|
||||
},
|
||||
"Удаление контакта \"%1$@\" появится позже." : {
|
||||
"comment" : "Contacts delete placeholder message"
|
||||
},
|
||||
@ -2720,6 +2732,9 @@
|
||||
"Удалить контакт" : {
|
||||
"comment" : "Contacts context action delete"
|
||||
},
|
||||
"Удалить фото" : {
|
||||
"comment" : "Avatar delete"
|
||||
},
|
||||
"Удалить чат (скоро)" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
|
||||
@ -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("Изменить фото") {
|
||||
@ -99,7 +114,7 @@ struct EditProfileView: View {
|
||||
.navigationTitle("Профиль")
|
||||
.onAppear(perform: loadProfile)
|
||||
.sheet(isPresented: $showImagePicker) {
|
||||
ImagePicker(image: $avatarImage)
|
||||
ImagePicker(image: $avatarImage, allowsEditing: true)
|
||||
}
|
||||
.onChange(of: avatarImage) { newValue in
|
||||
guard let image = newValue else { return }
|
||||
@ -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,15 +285,90 @@ struct EditProfileView: View {
|
||||
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
|
||||
}
|
||||
|
||||
@ -282,10 +386,313 @@ struct ImagePicker: UIViewControllerRepresentable {
|
||||
}
|
||||
|
||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||
if let uiImage = info[.originalImage] as? UIImage {
|
||||
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) {}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user