fix: update integration_test.go to match server.New signature (version, commit args)
All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
This commit is contained in:
702
ios/LibNovelV2/Views/Profile/ProfileView.swift
Normal file
702
ios/LibNovelV2/Views/Profile/ProfileView.swift
Normal file
@@ -0,0 +1,702 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
// MARK: - ProfileViewModel
|
||||
// Loads and manages active sessions. Uses @Observable (iOS 17+).
|
||||
|
||||
@Observable @MainActor
|
||||
final class ProfileViewModel {
|
||||
var sessions: [UserSession] = []
|
||||
var sessionsLoading = false
|
||||
var error: String?
|
||||
|
||||
func loadSessions() async {
|
||||
sessionsLoading = true
|
||||
error = nil
|
||||
do {
|
||||
sessions = try await APIClient.shared.sessions()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
sessionsLoading = false
|
||||
}
|
||||
|
||||
func revokeSession(id: String) async {
|
||||
do {
|
||||
try await APIClient.shared.revokeSession(id: id)
|
||||
sessions.removeAll { $0.id == id }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ProfileView
|
||||
// Full-screen profile/account management tab.
|
||||
|
||||
struct ProfileView: View {
|
||||
@EnvironmentObject private var authStore: AuthStore
|
||||
@EnvironmentObject private var networkMonitor: NetworkMonitor
|
||||
@State private var vm = ProfileViewModel()
|
||||
|
||||
@State private var showChangePassword = false
|
||||
@State private var showVoiceSelection = false
|
||||
@State private var showDownloads = false
|
||||
|
||||
// Avatar upload
|
||||
@State private var photoPickerItem: PhotosPickerItem?
|
||||
@State private var pendingCropImage: UIImage?
|
||||
@State private var localAvatarURL: String?
|
||||
@State private var avatarUploading = false
|
||||
@State private var avatarError: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
OfflineBanner()
|
||||
|
||||
List {
|
||||
// ── User header ─────────────────────────────────────────
|
||||
Section {
|
||||
HStack(spacing: 16) {
|
||||
avatarPickerView
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(authStore.user?.username ?? "")
|
||||
.font(.headline)
|
||||
Text(authStore.user?.role.capitalized ?? "")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let err = avatarError {
|
||||
Text(err)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
// ── Reading settings ─────────────────────────────────────
|
||||
Section("Reading Settings") {
|
||||
// Voice picker row — opens VoiceSelectionView sheet
|
||||
Button {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
showVoiceSelection = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("TTS Voice")
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Text(formatVoiceLabel(authStore.settings.voice))
|
||||
.foregroundStyle(.secondary)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("TTS Voice: \(formatVoiceLabel(authStore.settings.voice)). Tap to change.")
|
||||
|
||||
// Speed slider
|
||||
speedSliderRow
|
||||
|
||||
// Auto-advance toggle
|
||||
Toggle("Auto-advance chapter", isOn: Binding(
|
||||
get: { authStore.settings.autoNext },
|
||||
set: { newVal in
|
||||
Task {
|
||||
var s = authStore.settings
|
||||
s.autoNext = newVal
|
||||
await authStore.saveSettings(s)
|
||||
}
|
||||
}
|
||||
))
|
||||
.tint(Color.amber)
|
||||
|
||||
// Downloads row
|
||||
Button {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
showDownloads = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Downloads")
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Active sessions ──────────────────────────────────────
|
||||
Section("Active Sessions") {
|
||||
if vm.sessionsLoading {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
} else if vm.sessions.isEmpty {
|
||||
Text("No sessions found")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(vm.sessions) { session in
|
||||
SessionRow(session: session) {
|
||||
Task { await vm.revokeSession(id: session.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Account ───────────────────────────────────────────────
|
||||
Section("Account") {
|
||||
Button("Change Password") {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
showChangePassword = true
|
||||
}
|
||||
Button("Sign Out", role: .destructive) {
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
Task { await authStore.logout() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
|
||||
.navigationTitle("Profile")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.task {
|
||||
guard networkMonitor.isConnected else { return }
|
||||
await vm.loadSessions()
|
||||
}
|
||||
.sheet(isPresented: $showChangePassword) {
|
||||
ChangePasswordView()
|
||||
}
|
||||
.sheet(isPresented: $showVoiceSelection) {
|
||||
VoiceSelectionView(currentVoice: authStore.settings.voice)
|
||||
}
|
||||
.sheet(isPresented: $showDownloads) {
|
||||
DownloadsView()
|
||||
}
|
||||
.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(Binding(
|
||||
get: { vm.error },
|
||||
set: { vm.error = $0 }
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Avatar upload
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private func uploadCroppedData(_ data: Data) async {
|
||||
avatarUploading = true
|
||||
avatarError = nil
|
||||
defer { avatarUploading = false }
|
||||
do {
|
||||
let url = try await APIClient.shared.uploadAvatar(data, mimeType: "image/jpeg")
|
||||
localAvatarURL = url
|
||||
await authStore.validateToken()
|
||||
} catch {
|
||||
avatarError = "Upload failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Avatar picker view
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarPickerView: some View {
|
||||
PhotosPicker(selection: $photoPickerItem,
|
||||
matching: .images,
|
||||
photoLibrary: .shared()) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(uiColor: .systemGray5))
|
||||
.frame(width: 72, height: 72)
|
||||
|
||||
if avatarUploading {
|
||||
ProgressView()
|
||||
.frame(width: 72, height: 72)
|
||||
} else {
|
||||
let urlStr = localAvatarURL ?? authStore.user?.avatarURL
|
||||
if let urlStr, !urlStr.isEmpty {
|
||||
AsyncImage(url: URL(string: urlStr)) { phase in
|
||||
switch phase {
|
||||
case .success(let img):
|
||||
img.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(Circle())
|
||||
default:
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(Color.amber)
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(Color.amber)
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
}
|
||||
|
||||
// Camera badge
|
||||
if !avatarUploading {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.amber)
|
||||
.frame(width: 22, height: 22)
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
.offset(x: 2, y: 2)
|
||||
}
|
||||
}
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Change avatar photo")
|
||||
.onChange(of: photoPickerItem) { _, item in
|
||||
guard let item else { return }
|
||||
Task { await loadImageForCrop(item) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Speed slider row
|
||||
|
||||
@ViewBuilder
|
||||
private var speedSliderRow: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("Playback Speed")
|
||||
Spacer()
|
||||
Text("\(authStore.settings.speed, specifier: "%.2g")×")
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { authStore.settings.speed },
|
||||
set: { newSpeed in
|
||||
Task {
|
||||
var s = authStore.settings
|
||||
s.speed = newSpeed
|
||||
await authStore.saveSettings(s)
|
||||
}
|
||||
}
|
||||
),
|
||||
in: 0.5...2.0, step: 0.25
|
||||
)
|
||||
.tint(Color.amber)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func formatVoiceLabel(_ voice: String) -> String {
|
||||
let parts = voice.split(separator: "_")
|
||||
guard parts.count >= 2 else { return voice }
|
||||
return parts.dropFirst().map { $0.capitalized }.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SessionRow
|
||||
|
||||
private struct SessionRow: View {
|
||||
let session: UserSession
|
||||
let onRevoke: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "iphone")
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
Text(session.userAgent.isEmpty ? "Unknown device" : session.userAgent)
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if session.isCurrent {
|
||||
Text("This device")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(Color.amber)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.amber.opacity(0.12), in: Capsule())
|
||||
} else {
|
||||
Button("Revoke", role: .destructive, action: onRevoke)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
Text("Last seen \(session.lastSeen.prefix(10))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CropImageItem
|
||||
|
||||
private struct CropImageItem: Identifiable {
|
||||
let id = UUID()
|
||||
let image: UIImage
|
||||
}
|
||||
|
||||
// MARK: - ChangePasswordView
|
||||
|
||||
struct ChangePasswordView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var authStore: AuthStore
|
||||
|
||||
@State private var current = ""
|
||||
@State private var newPwd = ""
|
||||
@State private var confirm = ""
|
||||
@State private var isLoading = false
|
||||
@State private var error: String?
|
||||
@State private var success = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
SecureField("Current password", text: $current)
|
||||
SecureField("New password", text: $newPwd)
|
||||
SecureField("Confirm new password", text: $confirm)
|
||||
}
|
||||
if let error {
|
||||
Section {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
if success {
|
||||
Section {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text("Password changed successfully")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Change Password")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Button("Save") { save() }
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(Color.amber)
|
||||
.disabled(current.isEmpty || newPwd.count < 4 || newPwd != confirm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard newPwd == confirm else { error = "Passwords do not match"; return }
|
||||
isLoading = true
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
struct Body: Encodable { let currentPassword, newPassword: String }
|
||||
let _: EmptyResponse = try await APIClient.shared.fetch(
|
||||
"/api/auth/change-password", method: "POST",
|
||||
body: Body(currentPassword: current, newPassword: newPwd)
|
||||
)
|
||||
success = true
|
||||
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||
dismiss()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AvatarToolbarButton
|
||||
// Drop-in toolbar button showing the user's avatar. Opens the profile tab or an account sheet.
|
||||
|
||||
struct AvatarToolbarButton: View {
|
||||
@EnvironmentObject private var authStore: AuthStore
|
||||
@State private var showAccount = false
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
showAccount = true
|
||||
} label: {
|
||||
AvatarThumb(urlString: authStore.user?.avatarURL, size: 30)
|
||||
}
|
||||
.accessibilityLabel("Account")
|
||||
.sheet(isPresented: $showAccount) {
|
||||
ProfileView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AvatarThumb
|
||||
// Small circular avatar used in toolbars and list headers.
|
||||
|
||||
struct AvatarThumb: View {
|
||||
let urlString: String?
|
||||
let size: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let str = urlString, let url = URL(string: str) {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let img):
|
||||
img.resizable().scaledToFill()
|
||||
default:
|
||||
placeholderFill
|
||||
}
|
||||
}
|
||||
} else {
|
||||
placeholderFill
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(Color.amber.opacity(0.6), lineWidth: 1.5))
|
||||
}
|
||||
|
||||
private var placeholderFill: some View {
|
||||
Circle()
|
||||
.fill(Color(uiColor: .systemGray4))
|
||||
.overlay(
|
||||
Image(systemName: "person.fill")
|
||||
.font(.system(size: size * 0.5))
|
||||
.foregroundStyle(Color.amber)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AvatarCropView
|
||||
// Sheet that lets the user pan and pinch a photo to fill a 1:1 circular crop region.
|
||||
|
||||
struct AvatarCropView: View {
|
||||
let image: UIImage
|
||||
let onConfirm: (Data) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
private let cropSize: CGFloat = 280
|
||||
|
||||
@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
|
||||
@State private var containerSize: CGSize = .zero
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
GeometryReader { geo in
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
.scaleEffect(scale, anchor: .center)
|
||||
.offset(offset)
|
||||
.gesture(
|
||||
SimultaneousGesture(
|
||||
MagnificationGesture()
|
||||
.onChanged { value in
|
||||
let proposed = lastScale * value
|
||||
scale = max(1.0, proposed)
|
||||
}
|
||||
.onEnded { _ in
|
||||
lastScale = scale
|
||||
offset = clampedOffset(offset, in: geo.size)
|
||||
lastOffset = offset
|
||||
},
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
let proposed = CGSize(
|
||||
width: lastOffset.width + value.translation.width,
|
||||
height: lastOffset.height + value.translation.height
|
||||
)
|
||||
offset = clampedOffset(proposed, in: geo.size)
|
||||
}
|
||||
.onEnded { _ in lastOffset = offset }
|
||||
)
|
||||
)
|
||||
.clipped()
|
||||
|
||||
CropOverlay(cropSize: cropSize, containerSize: geo.size)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.onAppear {
|
||||
containerSize = geo.size
|
||||
scale = 1.0; lastScale = 1.0
|
||||
offset = .zero; lastOffset = .zero
|
||||
}
|
||||
}
|
||||
.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(Color.amber)
|
||||
}
|
||||
}
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Clamp helpers
|
||||
|
||||
private func displayedImageSize(in containerSize: CGSize, userScale: CGFloat) -> CGSize {
|
||||
let imgAspect = image.size.width / image.size.height
|
||||
let conAspect = containerSize.width / containerSize.height
|
||||
let baseW: CGFloat
|
||||
let baseH: CGFloat
|
||||
if imgAspect > conAspect {
|
||||
baseH = containerSize.height; baseW = baseH * imgAspect
|
||||
} else {
|
||||
baseW = containerSize.width; baseH = baseW / imgAspect
|
||||
}
|
||||
return CGSize(width: baseW * userScale, height: baseH * userScale)
|
||||
}
|
||||
|
||||
private func clampedOffset(_ proposed: CGSize, in containerSize: CGSize) -> CGSize {
|
||||
let displayed = displayedImageSize(in: containerSize, userScale: scale)
|
||||
let maxX = max(0, (displayed.width - cropSize) / 2)
|
||||
let maxY = max(0, (displayed.height - cropSize) / 2)
|
||||
return CGSize(
|
||||
width: min(maxX, max(-maxX, proposed.width)),
|
||||
height: min(maxY, max(-maxY, proposed.height))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Confirm crop
|
||||
|
||||
private func confirmCrop() {
|
||||
let size = containerSize.width > 0 ? containerSize : CGSize(width: 390, height: 844)
|
||||
let outputSize = CGSize(width: 400, height: 400)
|
||||
|
||||
let imgAspect = image.size.width / image.size.height
|
||||
let conAspect = size.width / size.height
|
||||
let baseDisplayW: CGFloat
|
||||
let baseDisplayH: CGFloat
|
||||
if imgAspect > conAspect {
|
||||
baseDisplayH = size.height; baseDisplayW = baseDisplayH * imgAspect
|
||||
} else {
|
||||
baseDisplayW = size.width; baseDisplayH = baseDisplayW / imgAspect
|
||||
}
|
||||
let displayW = baseDisplayW * scale
|
||||
let displayH = baseDisplayH * scale
|
||||
|
||||
let imageCentreX = size.width / 2 + offset.width
|
||||
let imageCentreY = size.height / 2 + offset.height
|
||||
let cropOriginX = (size.width - cropSize) / 2
|
||||
let cropOriginY = (size.height - cropSize) / 2
|
||||
let imageOriginX = imageCentreX - displayW / 2
|
||||
let imageOriginY = imageCentreY - displayH / 2
|
||||
let cropInImageX = cropOriginX - imageOriginX
|
||||
let cropInImageY = cropOriginY - imageOriginY
|
||||
|
||||
let dtpX = image.size.width / displayW
|
||||
let dtpY = image.size.height / displayH
|
||||
let cropRect = CGRect(
|
||||
x: cropInImageX * dtpX, y: cropInImageY * dtpY,
|
||||
width: cropSize * dtpX, height: cropSize * dtpY
|
||||
).intersection(CGRect(origin: .zero, size: image.size))
|
||||
|
||||
guard cropRect.width > 0, cropRect.height > 0 else {
|
||||
if let jpeg = image.jpegData(compressionQuality: 0.9) { onConfirm(jpeg) }
|
||||
return
|
||||
}
|
||||
|
||||
let renderer = UIGraphicsImageRenderer(size: outputSize)
|
||||
let cropped = renderer.image { _ in
|
||||
if let cgImg = image.cgImage?.cropping(to: cropRect) {
|
||||
UIImage(cgImage: cgImg, scale: image.scale,
|
||||
orientation: image.imageOrientation)
|
||||
.draw(in: CGRect(origin: .zero, size: outputSize))
|
||||
} else {
|
||||
image.draw(in: CGRect(origin: .zero, size: outputSize))
|
||||
}
|
||||
}
|
||||
if let jpeg = cropped.jpegData(compressionQuality: 0.9) { onConfirm(jpeg) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CropOverlay (internal)
|
||||
|
||||
private struct CropOverlay: View {
|
||||
let cropSize: CGFloat
|
||||
let containerSize: CGSize
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.black.opacity(0.55)))
|
||||
let origin = CGPoint(x: (size.width - cropSize) / 2, y: (size.height - cropSize) / 2)
|
||||
let rect = CGRect(origin: origin, size: CGSize(width: cropSize, height: cropSize))
|
||||
context.blendMode = .destinationOut
|
||||
context.fill(Path(ellipseIn: rect), with: .color(.white))
|
||||
}
|
||||
.compositingGroup()
|
||||
.overlay {
|
||||
let ox = (containerSize.width - cropSize) / 2
|
||||
let oy = (containerSize.height - cropSize) / 2
|
||||
Circle()
|
||||
.stroke(Color.amber.opacity(0.8), lineWidth: 2)
|
||||
.frame(width: cropSize, height: cropSize)
|
||||
.position(x: ox + cropSize / 2, y: oy + cropSize / 2)
|
||||
}
|
||||
.frame(width: containerSize.width, height: containerSize.height)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user