Skip to content
Open
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
54 changes: 42 additions & 12 deletions Resell/Views/Settings/AvailabilitySettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// Created by Charles Liggins on 1/12/26.
//

import LucideIcons
import SwiftUI

struct AvailabilitySettingsView: View {
Expand All @@ -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<CellIdentifier> = []
@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,
Expand All @@ -38,6 +41,10 @@ struct AvailabilitySettingsView: View {
private var monthName: String {
CalendarHelper.monthName(for: currentMonthOffset)
}

private var hasUnsavedChanges: Bool {
selectedCells != savedCells
}

// MARK: - Body

Expand Down Expand Up @@ -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)
Expand All @@ -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") {
Expand All @@ -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)
Expand Down Expand Up @@ -245,6 +270,7 @@ struct AvailabilitySettingsView: View {
let scheduleDates = Set(response.availability.schedule.keys)
await MainActor.run {
selectedCells = cells
savedCells = cells
knownScheduleDates = scheduleDates
}
} catch {
Expand All @@ -253,6 +279,8 @@ struct AvailabilitySettingsView: View {
}

private func saveAvailability() async {
guard hasUnsavedChanges, !isSaving else { return }

isSaving = true
defer { isSaving = false }

Expand All @@ -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 {
Expand Down
206 changes: 205 additions & 1 deletion Resell/Views/Settings/EditProfileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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()
}
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
}