diff --git a/ios/LibNovel/LibNovel/Models/Models.swift b/ios/LibNovel/LibNovel/Models/Models.swift index a9933bc..fa8c5e3 100644 --- a/ios/LibNovel/LibNovel/Models/Models.swift +++ b/ios/LibNovel/LibNovel/Models/Models.swift @@ -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) diff --git a/ios/LibNovel/LibNovel/Services/AuthStore.swift b/ios/LibNovel/LibNovel/Services/AuthStore.swift index 6f46ef0..88505f4 100644 --- a/ios/LibNovel/LibNovel/Services/AuthStore.swift +++ b/ios/LibNovel/LibNovel/Services/AuthStore.swift @@ -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 { diff --git a/ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift b/ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift new file mode 100644 index 0000000..0670a6b --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift @@ -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) + } +} diff --git a/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift b/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift index cd88bfb..667bb3c 100644 --- a/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift +++ b/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift @@ -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 +}