Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 0s
CI / Scraper / Test (pull_request) Successful in 11s
CI / UI / Build (pull_request) Failing after 14s
CI / Scraper / Lint (pull_request) Successful in 23s
CI / Scraper / Build (pull_request) Successful in 24s
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
- iOS SwiftUI app (ios/LibNovel/) targeting iOS 17+, generated via xcodegen - Full feature set: auth, home, library, book detail, chapter reader, browse, audio player, profile - Kingfisher for image loading, swift-markdown-ui for chapter rendering - Base URL: https://v2.libnovel.kalekber.cc - SvelteKit JSON API routes (ui/src/routes/api/) for iOS consumption: auth/login, auth/register, auth/me, auth/logout, auth/change-password, home, library, book/[slug], chapter/[slug]/[n], search, ranking, progress/[slug], presign/audio (updated) - Gitea Actions CI: .gitea/workflows/ios.yaml (build + test on macos-latest) - justfile: ios-gen, ios-build, ios-test recipes
219 lines
7.4 KiB
Swift
219 lines
7.4 KiB
Swift
import SwiftUI
|
||
|
||
struct ProfileView: View {
|
||
@EnvironmentObject var authStore: AuthStore
|
||
@StateObject private var vm = ProfileViewModel()
|
||
@State private var showChangePassword = false
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
List {
|
||
// User header
|
||
Section {
|
||
HStack(spacing: 14) {
|
||
Image(systemName: "person.circle.fill")
|
||
.font(.system(size: 48))
|
||
.foregroundStyle(.amber)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(authStore.user?.username ?? "")
|
||
.font(.headline)
|
||
Text(authStore.user?.role.capitalized ?? "")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.padding(.vertical, 6)
|
||
}
|
||
|
||
// Reading settings
|
||
Section("Reading Settings") {
|
||
voicePicker
|
||
speedSlider
|
||
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(.amber)
|
||
}
|
||
|
||
// Sessions
|
||
Section("Active Sessions") {
|
||
if vm.sessionsLoading {
|
||
ProgressView()
|
||
} else {
|
||
ForEach(vm.sessions) { session in
|
||
SessionRow(session: session) {
|
||
Task { await vm.revokeSession(id: session.id) }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Account
|
||
Section("Account") {
|
||
Button("Change Password") { showChangePassword = true }
|
||
Button("Sign Out", role: .destructive) {
|
||
Task { await authStore.logout() }
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Profile")
|
||
.task { await vm.loadSessions() }
|
||
.sheet(isPresented: $showChangePassword) {
|
||
ChangePasswordView()
|
||
}
|
||
.alert("Error", isPresented: .constant(vm.error != nil)) {
|
||
Button("OK") { vm.error = nil }
|
||
} message: { Text(vm.error ?? "") }
|
||
}
|
||
}
|
||
|
||
// MARK: - Voice picker
|
||
|
||
@ViewBuilder
|
||
private var voicePicker: some View {
|
||
Picker("TTS Voice", selection: Binding(
|
||
get: { authStore.settings.voice },
|
||
set: { newVoice in
|
||
Task {
|
||
var s = authStore.settings
|
||
s.voice = newVoice
|
||
await authStore.saveSettings(s)
|
||
}
|
||
}
|
||
)) {
|
||
if vm.voices.isEmpty {
|
||
Text("Default").tag("af_bella")
|
||
} else {
|
||
ForEach(vm.voices, id: \.self) { v in
|
||
Text(v).tag(v)
|
||
}
|
||
}
|
||
}
|
||
.task { await vm.loadVoices() }
|
||
}
|
||
|
||
// MARK: - Speed slider
|
||
|
||
@ViewBuilder
|
||
private var speedSlider: some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack {
|
||
Text("Playback Speed")
|
||
Spacer()
|
||
Text("\(authStore.settings.speed, specifier: "%.1f")×")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
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...3.0, step: 0.25
|
||
)
|
||
.tint(.amber)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Session row
|
||
|
||
private struct SessionRow: View {
|
||
let session: UserSession
|
||
let onRevoke: () -> Void
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack {
|
||
Image(systemName: "iphone")
|
||
Text(session.userAgent.isEmpty ? "Unknown device" : session.userAgent)
|
||
.font(.subheadline)
|
||
.lineLimit(1)
|
||
Spacer()
|
||
if session.isCurrent {
|
||
Text("This device")
|
||
.font(.caption2.bold())
|
||
.foregroundStyle(.amber)
|
||
} else {
|
||
Button("Revoke", role: .destructive, action: onRevoke)
|
||
.font(.caption)
|
||
}
|
||
}
|
||
Text("Last seen: \(session.lastSeen.prefix(10))")
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Change password sheet
|
||
|
||
struct ChangePasswordView: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
@EnvironmentObject 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 {
|
||
Text(error).foregroundStyle(.red).font(.caption)
|
||
}
|
||
if success {
|
||
Text("Password changed successfully").foregroundStyle(.green).font(.caption)
|
||
}
|
||
}
|
||
.navigationTitle("Change Password")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } }
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button("Save") { save() }
|
||
.disabled(isLoading || newPwd.count < 4 || newPwd != confirm)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { dismiss() }
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
isLoading = false
|
||
}
|
||
}
|
||
}
|