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"
|
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 {
|
init(from decoder: Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
id = try c.decode(String.self, forKey: .id)
|
id = try c.decode(String.self, forKey: .id)
|
||||||
|
|||||||
@@ -100,7 +100,20 @@ final class AuthStore: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
async let me: AppUser = APIClient.shared.fetch("/api/auth/me")
|
async let me: AppUser = APIClient.shared.fetch("/api/auth/me")
|
||||||
async let s: UserSettings = APIClient.shared.settings()
|
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
|
user = restoredUser
|
||||||
settings = restoredSettings
|
settings = restoredSettings
|
||||||
} catch let e as APIError {
|
} 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
|
// Avatar upload state
|
||||||
@State private var photoPickerItem: PhotosPickerItem?
|
@State private var photoPickerItem: PhotosPickerItem?
|
||||||
|
@State private var pendingCropImage: UIImage? // image waiting to be cropped
|
||||||
@State private var avatarURL: String? = nil
|
@State private var avatarURL: String? = nil
|
||||||
@State private var avatarUploading = false
|
@State private var avatarUploading = false
|
||||||
@State private var avatarError: String?
|
@State private var avatarError: String?
|
||||||
@@ -74,7 +75,7 @@ struct ProfileView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.onChange(of: photoPickerItem) { _, item in
|
.onChange(of: photoPickerItem) { _, item in
|
||||||
guard let item else { return }
|
guard let item else { return }
|
||||||
Task { await uploadPickedPhoto(item) }
|
Task { await loadImageForCrop(item) }
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
@@ -138,35 +139,40 @@ struct ProfileView: View {
|
|||||||
.sheet(isPresented: $showChangePassword) {
|
.sheet(isPresented: $showChangePassword) {
|
||||||
ChangePasswordView()
|
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)
|
.errorAlert($vm.error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Avatar upload
|
// 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
|
avatarUploading = true
|
||||||
avatarError = nil
|
avatarError = nil
|
||||||
defer { avatarUploading = false }
|
defer { avatarUploading = false }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
guard let data = try await item.loadTransferable(type: Data.self) else {
|
let url = try await APIClient.shared.uploadAvatar(data, mimeType: "image/jpeg")
|
||||||
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)
|
|
||||||
avatarURL = url
|
avatarURL = url
|
||||||
// Refresh user record so the new avatar persists across sessions
|
// Refresh user record so the new avatar persists across sessions
|
||||||
await authStore.validateToken()
|
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