Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions MC1/Resources/Generated/L10n.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1348,6 +1348,14 @@ public enum L10n {
public static let removePhoto = L10n.tr("Contacts", "contacts.detail.avatar.removePhoto", fallback: "Remove Photo")
/// Location: ContactDetailView.swift - Purpose: VoiceOver suffix announced while a new profile picture is being saved
public static let savingAnnouncement = L10n.tr("Contacts", "contacts.detail.avatar.savingAnnouncement", fallback: "Saving photo")
public enum Crop {
/// Location: AvatarCropView.swift - Purpose: Crop screen cancel button
public static let cancel = L10n.tr("Contacts", "contacts.detail.avatar.crop.cancel", fallback: "Cancel")
/// Location: AvatarCropView.swift - Purpose: Crop screen confirm button
public static let choose = L10n.tr("Contacts", "contacts.detail.avatar.crop.choose", fallback: "Choose")
/// Location: AvatarCropView.swift - Purpose: Crop screen navigation title
public static let title = L10n.tr("Contacts", "contacts.detail.avatar.crop.title", fallback: "Move and Scale")
}
}
public enum Error {
/// Location: ContactDetailView.swift - Purpose: Clear messages services-unavailable error
Expand Down
9 changes: 9 additions & 0 deletions MC1/Resources/Localization/en.lproj/Contacts.strings
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,15 @@
/* Location: ContactDetailView.swift - Purpose: Error shown when the picked file isn't a valid image */
"contacts.detail.avatar.invalidImage" = "That file couldn't be used as a profile picture.";

/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */
"contacts.detail.avatar.crop.title" = "Move and Scale";

/* Location: AvatarCropView.swift - Purpose: Crop screen cancel button */
"contacts.detail.avatar.crop.cancel" = "Cancel";

/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */
"contacts.detail.avatar.crop.choose" = "Choose";

/* Location: ContactDetailView.swift - Purpose: VoiceOver suffix announced while a new profile picture is being saved */
"contacts.detail.avatar.savingAnnouncement" = "Saving photo";

Expand Down
157 changes: 157 additions & 0 deletions MC1/Views/Components/AvatarCropView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import SwiftUI
import UIKit

/// Lets the user pan and zoom a picked image within a circular guide before it's
/// saved as a contact's profile picture, and crops it down to just that region.
struct AvatarCropView: View {
let image: UIImage
let onCancel: () -> Void
let onComplete: (UIImage) -> Void

/// Side length, in points, of the square crop guide shown on screen.
private let cropSize: CGFloat = 300

/// Allowed zoom range, applied both live during a pinch and once it ends.
private let minScale: CGFloat = 1
private let maxScale: CGFloat = 4

@GestureState private var dragTranslation: CGSize = .zero
@GestureState private var pinchDelta: CGFloat = 1

@State private var offset: CGSize = .zero
@State private var scale: CGFloat = 1

var body: some View {
NavigationStack {
ZStack {
Color.black.ignoresSafeArea()

Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: baseDisplaySize.width, height: baseDisplaySize.height)
.scaleEffect(clampedScale(scale * pinchDelta))
.offset(x: offset.width + dragTranslation.width, y: offset.height + dragTranslation.height)
.frame(width: cropSize, height: cropSize)
.clipped()
.contentShape(Rectangle())
.gesture(dragGesture)
.simultaneousGesture(magnificationGesture)

Circle()
.strokeBorder(Color.white, lineWidth: 2)
.frame(width: cropSize, height: cropSize)
.allowsHitTesting(false)

dimmingMask
.allowsHitTesting(false)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.navigationTitle(L10n.Contacts.Contacts.Detail.Avatar.Crop.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.Contacts.Contacts.Detail.Avatar.Crop.cancel, action: onCancel)
}
ToolbarItem(placement: .confirmationAction) {
Button(L10n.Contacts.Contacts.Detail.Avatar.Crop.choose) {
onComplete(croppedImage())
}
}
}
}
}

/// A full-bleed dark scrim with a circular window cut out over the crop guide,
/// drawn with an even-odd fill so the two shapes combine into a single hole-punched path.
private var dimmingMask: some View {
GeometryReader { proxy in
Path { path in
path.addRect(CGRect(origin: .zero, size: proxy.size))
let circleRect = CGRect(
x: (proxy.size.width - cropSize) / 2,
y: (proxy.size.height - cropSize) / 2,
width: cropSize,
height: cropSize
)
path.addEllipse(in: circleRect)
}
.fill(Color.black.opacity(0.5), style: FillStyle(eoFill: true))
}
}

/// The image's size, in points, when scaled (via `.scaledToFill`) to just cover the crop square.
private var baseDisplaySize: CGSize {
let imageSize = image.size
guard imageSize.width > 0, imageSize.height > 0 else { return CGSize(width: cropSize, height: cropSize) }
let fillScale = max(cropSize / imageSize.width, cropSize / imageSize.height)
return CGSize(width: imageSize.width * fillScale, height: imageSize.height * fillScale)
}

private var dragGesture: some Gesture {
DragGesture()
.updating($dragTranslation) { value, state, _ in
state = value.translation
}
.onEnded { value in
offset = clampedOffset(
CGSize(width: offset.width + value.translation.width, height: offset.height + value.translation.height)
)
}
}

private var magnificationGesture: some Gesture {
MagnifyGesture()
.updating($pinchDelta) { value, state, _ in
state = value.magnification
}
.onEnded { value in
scale = clampedScale(scale * value.magnification)
offset = clampedOffset(offset)
}
}

private func clampedScale(_ proposed: CGFloat) -> CGFloat {
min(max(proposed, minScale), maxScale)
}

/// Keeps the displayed image covering the crop square at all times, regardless of pan/zoom.
private func clampedOffset(_ proposed: CGSize) -> CGSize {
let displayedSize = CGSize(width: baseDisplaySize.width * scale, height: baseDisplaySize.height * scale)
let maxOffsetX = max(0, (displayedSize.width - cropSize) / 2)
let maxOffsetY = max(0, (displayedSize.height - cropSize) / 2)
return CGSize(
width: min(max(proposed.width, -maxOffsetX), maxOffsetX),
height: min(max(proposed.height, -maxOffsetY), maxOffsetY)
)
}

/// Renders the portion of the source image currently visible inside the crop guide.
///
/// Draws via `UIImage.draw(in:)` rather than cropping `cgImage` directly, so that EXIF
/// orientation (e.g. a portrait photo shot with a rotated sensor) is honored exactly as
/// it is in the on-screen preview, which uses the same point-space geometry.
private func croppedImage() -> UIImage {
let outputSide: CGFloat = 1024
let renderScale = outputSide / cropSize

let displayedSize = CGSize(width: baseDisplaySize.width * scale, height: baseDisplaySize.height * scale)
let imageOrigin = CGPoint(
x: (cropSize - displayedSize.width) / 2 + offset.width,
y: (cropSize - displayedSize.height) / 2 + offset.height
)

let format = UIGraphicsImageRendererFormat()
format.scale = 1
let renderer = UIGraphicsImageRenderer(size: CGSize(width: outputSide, height: outputSide), format: format)
return renderer.image { _ in
let drawRect = CGRect(
x: imageOrigin.x * renderScale,
y: imageOrigin.y * renderScale,
width: displayedSize.width * renderScale,
height: displayedSize.height * renderScale
)
image.draw(in: drawRect)
}
}
}
85 changes: 79 additions & 6 deletions MC1/Views/Contacts/ContactDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ struct ContactDetailView: View {
}
}

/// Wraps a decoded avatar image so `fullScreenCover(item:)` can't present an empty cover.
private struct AvatarCropRequest: Identifiable {
let id = UUID()
let image: UIImage
}

@State private var currentContact: ContactDTO
@State private var nickname = ""
@State private var isEditingNickname = false
Expand Down Expand Up @@ -105,6 +111,10 @@ struct ContactDetailView: View {
@State private var showAvatarFileImporter = false
@State private var avatarPickerItem: PhotosPickerItem?
@State private var isSavingAvatar = false
/// A decoded image waiting for the photo picker / file importer sheet that produced it
/// to finish dismissing, so the crop cover isn't presented while another is still animating out.
@State private var pendingCropImage: UIImage?
@State private var cropRequest: AvatarCropRequest?

init(contact: ContactDTO, showFromDirectChat: Bool = false, onClearMessages: @escaping () -> Void = {}) {
self.contact = contact
Expand Down Expand Up @@ -273,9 +283,25 @@ struct ContactDetailView: View {
.onChange(of: avatarPickerItem) { _, newItem in
Task { await loadPickedAvatarPhoto(newItem) }
}
.onChange(of: showAvatarPhotosPicker) { _, isPresented in
if !isPresented { presentPendingCropIfReady() }
}
.fileImporter(isPresented: $showAvatarFileImporter, allowedContentTypes: [.image]) { result in
Task { await handleAvatarFileImport(result) }
}
.onChange(of: showAvatarFileImporter) { _, isPresented in
if !isPresented { presentPendingCropIfReady() }
}
.fullScreenCover(item: $cropRequest) { request in
AvatarCropView(
image: request.image,
onCancel: { cropRequest = nil },
onComplete: { cropped in
cropRequest = nil
Task { await saveAvatar(image: cropped) }
}
)
}
.task {
pathViewModel.configure(
dataStore: { appState.services?.dataStore },
Expand Down Expand Up @@ -535,7 +561,7 @@ struct ContactDetailView: View {
errorMessage = L10n.Contacts.Contacts.Detail.Avatar.invalidImage
return
}
await saveAvatar(data: data)
await presentCropSheet(data: data)
} catch {
errorMessage = error.userFacingMessage
}
Expand All @@ -548,7 +574,7 @@ struct ContactDetailView: View {
defer { if didAccess { url.stopAccessingSecurityScopedResource() } }
do {
let data = try Data(contentsOf: url)
await saveAvatar(data: data)
await presentCropSheet(data: data)
} catch {
errorMessage = error.userFacingMessage
}
Expand All @@ -557,10 +583,36 @@ struct ContactDetailView: View {
}
}

private func saveAvatar(data: Data) async {
/// Decodes off the main actor and downsamples to a display-sized bound before the crop
/// screen ever sees the image, so a 12-48MP camera photo doesn't hitch the UI or hold
/// its full-resolution bitmap in memory while cropping.
private func presentCropSheet(data: Data) async {
let decoded = await Task.detached(priority: .userInitiated) {
Self.downsampledImage(data: data, maxPixelSize: Self.cropMaxPixelSize)
}.value
guard let image = decoded else {
errorMessage = L10n.Contacts.Contacts.Detail.Avatar.invalidImage
return
}
pendingCropImage = image
// The picker/importer sheet may still be animating its dismissal; onChange above
// presents the pending image once it reports fully closed. If it's already closed
// by the time decoding finishes, present immediately.
if !showAvatarPhotosPicker, !showAvatarFileImporter {
presentPendingCropIfReady()
}
}

private func presentPendingCropIfReady() {
guard let image = pendingCropImage else { return }
pendingCropImage = nil
cropRequest = AvatarCropRequest(image: image)
}

private func saveAvatar(image: UIImage) async {
isSavingAvatar = true
let processed = await Task.detached(priority: .userInitiated) {
Self.processAvatarImage(data: data)
Self.processAvatarImage(image: image)
}.value
guard let processed else {
errorMessage = L10n.Contacts.Contacts.Detail.Avatar.invalidImage
Expand Down Expand Up @@ -590,10 +642,31 @@ struct ContactDetailView: View {
isSavingAvatar = false
}

/// Max pixel dimension the crop screen decodes and displays at; well above the 300pt
/// on-screen guide to stay sharp under the pinch zoom's 4x cap, but bounded so a raw
/// camera photo can't hold its full-resolution bitmap in memory while cropping.
private nonisolated static let cropMaxPixelSize: CGFloat = 1024

/// Decodes and downsamples via ImageIO instead of `UIImage(data:)`, so a large source
/// image is never fully decoded into memory. Mirrors `ImageURLDetector.downsampledImage(from:)`.
private nonisolated static func downsampledImage(data: Data, maxPixelSize: CGFloat) -> UIImage? {
let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions) else { return nil }
let downsampleOptions: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true
]
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions as CFDictionary) else {
return nil
}
return UIImage(cgImage: cgImage)
}

/// Downscales to a max 512pt dimension and re-encodes as JPEG so avatars stay small in the store.
/// `nonisolated` so it can run on a background thread via `Task.detached` in `saveAvatar`.
private nonisolated static func processAvatarImage(data: Data) -> Data? {
guard let image = UIImage(data: data) else { return nil }
private nonisolated static func processAvatarImage(image: UIImage) -> Data? {
let maxDimension: CGFloat = 512
let scale = min(1, maxDimension / max(image.size.width, image.size.height))
let targetSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
Expand Down
Loading