From 858d05ed0cae4e12fb287c75ed0eb69fe1b7eef1 Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 3 Sep 2026 01:45:46 -0400 Subject: [PATCH] Crop profile photos before upload and make availability saves explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking a profile photo used the raw image, so a landscape shot became a circle cropped through the middle of whatever the camera happened to centre. Photos now go through an adjust step — pinch, drag or use the slider inside the circular mask — and the crop is applied to pixels before upload rather than only to how the image is displayed. Pan and zoom are clamped to the image bounds so you can't drag empty space into frame. Availability's Save button moves into the toolbar and only enables when the grid differs from what was saved, which needed the view to track the saved selection rather than only the current one. Previously the button was inline, always enabled, and gave no acknowledgement — you couldn't tell a successful save from a no-op. A confirmation alert closes that loop. Cropping reuses `UIImage.flattenedOrientation()` instead of carrying its own copy of the EXIF-flattening routine. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017R8mua9xepzF3mnRETtd6W --- .../Settings/AvailabilitySettingsView.swift | 54 ++++- Resell/Views/Settings/EditProfileView.swift | 206 +++++++++++++++++- 2 files changed, 247 insertions(+), 13 deletions(-) diff --git a/Resell/Views/Settings/AvailabilitySettingsView.swift b/Resell/Views/Settings/AvailabilitySettingsView.swift index 7370af9..a0c5955 100644 --- a/Resell/Views/Settings/AvailabilitySettingsView.swift +++ b/Resell/Views/Settings/AvailabilitySettingsView.swift @@ -5,6 +5,7 @@ // Created by Charles Liggins on 1/12/26. // +import LucideIcons import SwiftUI struct AvailabilitySettingsView: View { @@ -23,6 +24,8 @@ struct AvailabilitySettingsView: View { @State private var isLoading: Bool = false @State private var isSaving: Bool = false @State private var errorMessage: String? = nil + @State private var savedCells: Set = [] + @State private var showSaveConfirmation = false /// Dates (yyyy-MM-dd) that exist in the saved schedule on the backend. /// Tracked so we can send empty arrays for them on save when the user clears cells, @@ -38,6 +41,10 @@ struct AvailabilitySettingsView: View { private var monthName: String { CalendarHelper.monthName(for: currentMonthOffset) } + + private var hasUnsavedChanges: Bool { + selectedCells != savedCells + } // MARK: - Body @@ -138,18 +145,6 @@ struct AvailabilitySettingsView: View { } ) .id(gridStartDate) // Force rebuild when start date changes - - if !showCalendar { - PurpleButton(isLoading: isSaving, text: isSaving ? "Saving..." : "Save") { - Task { - await saveAvailability() - } - } - .disabled(isSaving) - .padding(.top, 16) - .padding(.horizontal) - .padding(.bottom, 24) - } } } .scrollDisabled(true) @@ -175,6 +170,31 @@ struct AvailabilitySettingsView: View { ToolbarItem(placement: .navigationBarLeading) { BackButton() } + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { + await saveAvailability() + } + } label: { + if isSaving { + ProgressView() + .controlSize(.small) + } else { + Image(uiImage: Lucide.save) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + } + } + .foregroundStyle( + hasUnsavedChanges + ? Constants.Colors.resellPurple + : Constants.Colors.inactiveGray + ) + .disabled(!hasUnsavedChanges || isSaving || isLoading) + .accessibilityLabel("Save availability") + } } .alert("Error", isPresented: .constant(errorMessage != nil)) { Button("OK") { @@ -183,6 +203,11 @@ struct AvailabilitySettingsView: View { } message: { Text(errorMessage ?? "") } + .alert("Availability Updated", isPresented: $showSaveConfirmation) { + Button("OK", role: .cancel) {} + } message: { + Text("Your new availability settings have been saved.") + } .onAppear { // Initialize visible dates updateVisibleDates(from: gridStartDate, page: 0) @@ -245,6 +270,7 @@ struct AvailabilitySettingsView: View { let scheduleDates = Set(response.availability.schedule.keys) await MainActor.run { selectedCells = cells + savedCells = cells knownScheduleDates = scheduleDates } } catch { @@ -253,6 +279,8 @@ struct AvailabilitySettingsView: View { } private func saveAvailability() async { + guard hasUnsavedChanges, !isSaving else { return } + isSaving = true defer { isSaving = false } @@ -265,6 +293,8 @@ struct AvailabilitySettingsView: View { // we just sent (excluding the explicit empty-array clears). await MainActor.run { knownScheduleDates = Set(schedule.compactMap { $0.value.isEmpty ? nil : $0.key }) + savedCells = selectedCells + showSaveConfirmation = true } } catch { await MainActor.run { diff --git a/Resell/Views/Settings/EditProfileView.swift b/Resell/Views/Settings/EditProfileView.swift index cf3aceb..dd948a5 100644 --- a/Resell/Views/Settings/EditProfileView.swift +++ b/Resell/Views/Settings/EditProfileView.swift @@ -22,6 +22,8 @@ struct EditProfileView: View { @State private var selectedItem: PhotosPickerItem? = nil @State private var didShowPhotosPicker: Bool = false + @State private var pendingProfilePic: UIImage? + @State private var didShowImageCropper = false @FocusState private var focusedField: Field? @@ -76,6 +78,22 @@ struct EditProfileView: View { } } .loadingView(isLoading: profileManager.isLoading) + .fullScreenCover(isPresented: $didShowImageCropper) { + if let pendingProfilePic { + ProfileImageCropView( + image: pendingProfilePic, + onCancel: { + didShowImageCropper = false + self.pendingProfilePic = nil + }, + onSave: { croppedImage in + editedProfilePic = croppedImage + didShowImageCropper = false + self.pendingProfilePic = nil + } + ) + } + } .onAppear { loadCurrentValues() } @@ -85,6 +103,7 @@ struct EditProfileView: View { ZStack(alignment: .bottomTrailing) { Image(uiImage: editedProfilePic) .resizable() + .scaledToFill() .frame(width: 132, height: 132) .background(Constants.Colors.stroke) .clipShape(.circle) @@ -225,7 +244,192 @@ struct EditProfileView: View { if let data = try? await newItem.loadTransferable(type: Data.self), let image = UIImage(data: data) { - editedProfilePic = image + pendingProfilePic = image + didShowImageCropper = true + selectedItem = nil } } } + +private struct ProfileImageCropView: View { + let image: UIImage + let onCancel: () -> Void + let onSave: (UIImage) -> Void + + @State private var scale: CGFloat = 1 + @State private var lastScale: CGFloat = 1 + @State private var offset: CGSize = .zero + @State private var lastOffset: CGSize = .zero + + var body: some View { + NavigationStack { + GeometryReader { proxy in + let availableWidth = max(proxy.size.width - 32, 1) + let availableHeight = max(proxy.size.height * 0.58, 1) + let cropSize = max(min(availableWidth, availableHeight), 1) + + VStack(spacing: 28) { + Spacer() + + cropPreview(size: cropSize) + + VStack(spacing: 10) { + HStack { + Image(systemName: "minus.magnifyingglass") + Slider( + value: Binding( + get: { scale }, + set: { newScale in + scale = newScale + offset = clampedOffset( + offset, + cropSize: cropSize, + scale: newScale + ) + } + ), + in: 1...4, + onEditingChanged: { isEditing in + if !isEditing { + lastScale = scale + lastOffset = offset + } + } + ) + Image(systemName: "plus.magnifyingglass") + } + .foregroundStyle(Constants.Colors.black) + + Text("Pinch to zoom and drag to reposition") + .font(Constants.Fonts.subtitle1) + .foregroundStyle(Constants.Colors.secondaryGray) + } + .padding(.horizontal, 32) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Constants.Colors.white) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel", action: onCancel) + } + + ToolbarItem(placement: .principal) { + Text("Adjust Photo") + .font(Constants.Fonts.title1) + } + + ToolbarItem(placement: .topBarTrailing) { + Button("Use Photo") { + onSave(croppedImage(cropSize: cropSize)) + } + .font(Constants.Fonts.title2) + } + } + } + } + } + + private func cropPreview(size: CGFloat) -> some View { + let imageWidth = max(image.size.width, 1) + let imageHeight = max(image.size.height, 1) + let baseScale = max(size / imageWidth, size / imageHeight) + + return Image(uiImage: image) + .resizable() + .frame( + width: imageWidth * baseScale, + height: imageHeight * baseScale + ) + .scaleEffect(scale) + .offset(offset) + .frame(width: size, height: size) + .background(Constants.Colors.black) + .clipShape(Circle()) + .overlay { + Circle() + .stroke(Constants.Colors.white, lineWidth: 2) + } + .contentShape(Circle()) + .gesture( + DragGesture() + .onChanged { value in + offset = clampedOffset( + CGSize( + width: lastOffset.width + value.translation.width, + height: lastOffset.height + value.translation.height + ), + cropSize: size, + scale: scale + ) + } + .onEnded { _ in + lastOffset = offset + } + ) + .simultaneousGesture( + MagnificationGesture() + .onChanged { value in + scale = min(max(lastScale * value, 1), 4) + offset = clampedOffset(offset, cropSize: size, scale: scale) + } + .onEnded { _ in + lastScale = scale + lastOffset = offset + } + ) + } + + private func clampedOffset( + _ proposedOffset: CGSize, + cropSize: CGFloat, + scale: CGFloat + ) -> CGSize { + let imageWidth = max(image.size.width, 1) + let imageHeight = max(image.size.height, 1) + let baseScale = max(cropSize / imageWidth, cropSize / imageHeight) + let displayedWidth = imageWidth * baseScale * scale + let displayedHeight = imageHeight * baseScale * scale + let maximumX = max(0, (displayedWidth - cropSize) / 2) + let maximumY = max(0, (displayedHeight - cropSize) / 2) + + return CGSize( + width: min(max(proposedOffset.width, -maximumX), maximumX), + height: min(max(proposedOffset.height, -maximumY), maximumY) + ) + } + + private func croppedImage(cropSize: CGFloat) -> UIImage { + let normalizedImage = image.flattenedOrientation() + let imageSize = normalizedImage.size + let baseScale = max(cropSize / imageSize.width, cropSize / imageSize.height) + let displayScale = baseScale * scale + let cropSide = cropSize / displayScale + + let cropRect = CGRect( + x: min( + max((imageSize.width - cropSide) / 2 - offset.width / displayScale, 0), + imageSize.width - cropSide + ), + y: min( + max((imageSize.height - cropSide) / 2 - offset.height / displayScale, 0), + imageSize.height - cropSide + ), + width: cropSide, + height: cropSide + ) + + guard let source = normalizedImage.cgImage else { return normalizedImage } + let pixelScale = CGFloat(source.width) / imageSize.width + let pixelRect = CGRect( + x: cropRect.minX * pixelScale, + y: cropRect.minY * pixelScale, + width: cropRect.width * pixelScale, + height: cropRect.height * pixelScale + ).integral + + guard let cropped = source.cropping(to: pixelRect) else { return normalizedImage } + return UIImage(cgImage: cropped, scale: normalizedImage.scale, orientation: .up) + } +}