Files
libnovel/ios/LibNovelV2/Views/Profile/ProfileView.swift
Admin 7413313100
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
fix: update integration_test.go to match server.New signature (version, commit args)
2026-03-14 14:25:46 +05:00

703 lines
26 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}