Compare commits

...

7 Commits

Author SHA1 Message Date
9013549362 photo view 2025-12-10 03:18:32 +03:00
947a504f08 zoom fix 2025-12-10 03:09:53 +03:00
11a5cba8fa xy 2025-12-10 03:07:25 +03:00
e4b3a33ebc edit 2025-12-10 03:04:57 +03:00
f3653c5617 photo view edit 2025-12-10 02:59:17 +03:00
13a1d00934 add view img 2025-12-10 02:49:56 +03:00
134ba39914 add crop photo 2025-12-10 02:41:28 +03:00
2 changed files with 425 additions and 3 deletions

View File

@ -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" : {

View File

@ -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) {}
}