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
703 lines
26 KiB
Swift
703 lines
26 KiB
Swift
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)
|
||
}
|
||
}
|