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
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:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
161
ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift
Normal file
161
ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user