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) } }