fix(ios): wire avatar upload to presign flow with crop UI and cold-launch fix
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 28s
CI / UI / Docker Push (pull_request) Has been skipped
Release / Scraper / Test (push) Successful in 9s
Release / UI / Build (push) Successful in 28s
Release / Scraper / Docker (push) Successful in 24s
Release / UI / Docker (push) Successful in 3m6s
iOS CI / Build (push) Failing after 2m3s
iOS CI / Test (push) Has been skipped
iOS CI / Build (pull_request) Failing after 59s
iOS CI / Test (pull_request) Has been skipped

- Add AvatarCropView: fullscreen pan/pinch sheet with circular crop overlay,
  outputs 400×400 JPEG at 0.9 quality matching the web crop modal
- ProfileView: picker now shows crop sheet before uploading instead of direct upload
- AuthStore.validateToken: exchange raw MinIO key from /api/auth/me for a
  presigned GET URL so avatar renders correctly on cold launch / re-login
- APIClient: add fetchAvatarPresignedURL() calling GET /api/profile/avatar
- Models: add memberwise init to AppUser for avatar URL replacement
This commit is contained in:
Admin
2026-03-10 18:17:01 +05:00
parent 83a5910a59
commit c7b3495a23
4 changed files with 216 additions and 21 deletions

View File

@@ -178,6 +178,14 @@ struct AppUser: Codable, Identifiable {
case avatarURL = "avatar_url"
}
init(id: String, username: String, role: String, created: String, avatarURL: String?) {
self.id = id
self.username = username
self.role = role
self.created = created
self.avatarURL = avatarURL
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)

View File

@@ -100,7 +100,20 @@ final class AuthStore: ObservableObject {
do {
async let me: AppUser = APIClient.shared.fetch("/api/auth/me")
async let s: UserSettings = APIClient.shared.settings()
let (restoredUser, restoredSettings) = try await (me, s)
var (restoredUser, restoredSettings) = try await (me, s)
// /api/auth/me returns the raw MinIO object key for avatar_url, not a presigned URL.
// Exchange the key for a fresh presigned GET URL so KFImage can display it.
if let key = restoredUser.avatarURL, !key.hasPrefix("http") {
if let presignedURL = try? await APIClient.shared.fetchAvatarPresignedURL() {
restoredUser = AppUser(
id: restoredUser.id,
username: restoredUser.username,
role: restoredUser.role,
created: restoredUser.created,
avatarURL: presignedURL
)
}
}
user = restoredUser
settings = restoredSettings
} catch let e as APIError {

View File

@@ -0,0 +1,161 @@
import SwiftUI
// MARK: - AvatarCropView
// A sheet that lets the user pan and pinch a photo to fill a 1:1 square crop region.
// Call: .sheet(item: $cropImage) { AvatarCropView(image: $0.image, onConfirm: { croppedData in }) }
struct AvatarCropView: View {
let image: UIImage
let onConfirm: (Data) -> Void
let onCancel: () -> Void
// Crop square side length (points) matched to the web 400 px target
private let cropSize: CGFloat = 280
// Pan/zoom state
@State private var scale: CGFloat = 1.0
@State private var lastScale: CGFloat = 1.0
@State private var offset: CGSize = .zero
@State private var lastOffset: CGSize = .zero
var body: some View {
NavigationStack {
GeometryReader { geo in
ZStack {
Color.black.ignoresSafeArea()
// Draggable / pinchable image
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: geo.size.width, height: geo.size.height)
.scaleEffect(scale)
.offset(offset)
.gesture(
SimultaneousGesture(
MagnificationGesture()
.onChanged { value in
scale = max(1.0, lastScale * value)
}
.onEnded { _ in
lastScale = scale
},
DragGesture()
.onChanged { value in
offset = CGSize(
width: lastOffset.width + value.translation.width,
height: lastOffset.height + value.translation.height
)
}
.onEnded { _ in
lastOffset = offset
}
)
)
.clipped()
// Dim overlay with transparent crop square cut out
CropOverlay(cropSize: cropSize, containerSize: geo.size)
.allowsHitTesting(false)
}
}
.navigationTitle("Crop Photo")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel", action: onCancel)
.foregroundStyle(.white)
}
ToolbarItem(placement: .topBarTrailing) {
Button("Use Photo") {
confirmCrop()
}
.fontWeight(.semibold)
.foregroundStyle(.amber)
}
}
.toolbarColorScheme(.dark, for: .navigationBar)
}
.onAppear { fitImageInitially() }
}
// MARK: - Crop
private func fitImageInitially() {
// Scale image so its shorter dimension fills the crop square
let imgAspect = image.size.width / image.size.height
if imgAspect > 1 {
// wider than tall fit height to cropSize
scale = cropSize / image.size.height * (image.size.height / image.size.width)
} else {
scale = 1.0
}
scale = max(1.0, scale)
lastScale = scale
}
private func confirmCrop() {
// Render image at current pan/zoom into a 400×400 bitmap
let outputSize = CGSize(width: 400, height: 400)
let renderer = UIGraphicsImageRenderer(size: outputSize)
let cropped = renderer.image { ctx in
// We need to map from the SwiftUI transform back to image pixels.
// We render the raw UIImage into the output rect, applying the same
// scale / offset proportionally (normalised by crop square / container).
let screenCropSize: CGFloat = cropSize
// Scale factor: pixels per SwiftUI point in the output
let outputScale = outputSize.width / screenCropSize
ctx.cgContext.translateBy(x: outputSize.width / 2, y: outputSize.height / 2)
ctx.cgContext.scaleBy(x: scale * outputScale, y: scale * outputScale)
ctx.cgContext.translateBy(
x: -image.size.width / 2 + (offset.width * outputScale / scale),
y: -image.size.height / 2 + (offset.height * outputScale / scale)
)
image.draw(at: .zero)
}
if let jpeg = cropped.jpegData(compressionQuality: 0.9) {
onConfirm(jpeg)
}
}
}
// MARK: - Crop overlay
private struct CropOverlay: View {
let cropSize: CGFloat
let containerSize: CGSize
var body: some View {
Canvas { context, size in
// Fill entire canvas with semi-transparent black
context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.black.opacity(0.55)))
// Cut out the crop square in the centre
let origin = CGPoint(
x: (size.width - cropSize) / 2,
y: (size.height - cropSize) / 2
)
let cropRect = CGRect(origin: origin, size: CGSize(width: cropSize, height: cropSize))
context.blendMode = .destinationOut
context.fill(Path(ellipseIn: cropRect), with: .color(.white))
}
.compositingGroup()
.overlay {
// Amber circle border around the crop region
let origin = CGPoint(
x: (containerSize.width - cropSize) / 2,
y: (containerSize.height - cropSize) / 2
)
Circle()
.stroke(Color.amber.opacity(0.8), lineWidth: 2)
.frame(width: cropSize, height: cropSize)
.position(
x: origin.x + cropSize / 2,
y: origin.y + cropSize / 2
)
}
.frame(width: containerSize.width, height: containerSize.height)
.allowsHitTesting(false)
}
}

View File

@@ -9,6 +9,7 @@ struct ProfileView: View {
// Avatar upload state
@State private var photoPickerItem: PhotosPickerItem?
@State private var pendingCropImage: UIImage? // image waiting to be cropped
@State private var avatarURL: String? = nil
@State private var avatarUploading = false
@State private var avatarError: String?
@@ -74,7 +75,7 @@ struct ProfileView: View {
.buttonStyle(.plain)
.onChange(of: photoPickerItem) { _, item in
guard let item else { return }
Task { await uploadPickedPhoto(item) }
Task { await loadImageForCrop(item) }
}
VStack(alignment: .leading, spacing: 3) {
@@ -138,35 +139,40 @@ struct ProfileView: View {
.sheet(isPresented: $showChangePassword) {
ChangePasswordView()
}
.sheet(item: Binding(
get: { pendingCropImage.map { CropImageItem(image: $0) } },
set: { if $0 == nil { pendingCropImage = nil } }
)) { item in
AvatarCropView(image: item.image) { croppedData in
pendingCropImage = nil
Task { await uploadCroppedData(croppedData) }
} onCancel: {
pendingCropImage = nil
}
}
.errorAlert($vm.error)
}
}
// MARK: - Avatar upload
private func uploadPickedPhoto(_ item: PhotosPickerItem) async {
/// Step 1: Load the raw image from the picker and show the crop sheet.
private func loadImageForCrop(_ item: PhotosPickerItem) async {
guard let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) else {
avatarError = "Could not read image"
return
}
pendingCropImage = image
}
/// Step 2: Called by AvatarCropView once the user confirms. Upload the cropped JPEG.
private func uploadCroppedData(_ data: Data) async {
avatarUploading = true
avatarError = nil
defer { avatarUploading = false }
do {
guard let data = try await item.loadTransferable(type: Data.self) else {
avatarError = "Could not read image"
return
}
// Compress to JPEG for consistent handling
let mimeType: String
let uploadData: Data
if let uiImage = UIImage(data: data),
let jpeg = uiImage.jpegData(compressionQuality: 0.85) {
uploadData = jpeg
mimeType = "image/jpeg"
} else {
uploadData = data
mimeType = "image/png"
}
let url = try await APIClient.shared.uploadAvatar(uploadData, mimeType: mimeType)
let url = try await APIClient.shared.uploadAvatar(data, mimeType: "image/jpeg")
avatarURL = url
// Refresh user record so the new avatar persists across sessions
await authStore.validateToken()
@@ -318,3 +324,10 @@ struct ChangePasswordView: View {
}
}
}
// MARK: - Crop image item (Identifiable wrapper for .sheet(item:))
private struct CropImageItem: Identifiable {
let id = UUID()
let image: UIImage
}