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

This commit is contained in:
Admin
2026-03-14 14:25:46 +05:00
parent b11f4ab6b4
commit 7413313100
75 changed files with 16405 additions and 1897 deletions

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