v2 #1

Open
kamil wants to merge 231 commits from v2 into main
6 changed files with 609 additions and 4 deletions
Showing only changes of commit 4d3c093612 - Show all commits

View File

@@ -13,7 +13,7 @@ struct RootTabView: View {
@State private var fullPlayerDragOffset: CGFloat = 0
enum Tab: Hashable {
case home, library, browse, profile
case home, library, browse, search
}
/// Height of the mini player bar (progress line 2pt + vertical padding 20pt + content ~44pt)
@@ -34,9 +34,9 @@ struct RootTabView: View {
.tabItem { Label("Discover", systemImage: "sparkles") }
.tag(Tab.browse)
ProfileView()
.tabItem { Label("Profile", systemImage: "gear") }
.tag(Tab.profile)
SearchView()
.tabItem { Label("Search", systemImage: "magnifyingglass") }
.tag(Tab.search)
}
// Reserve space for the mini-player above the tab bar so scroll content
// never slides beneath it.

View File

@@ -92,6 +92,11 @@ struct BrowseView: View {
}
.navigationTitle("Discover")
.appNavigationDestination()
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
AvatarToolbarButton()
}
}
.sheet(isPresented: $showFilters) {
BrowseFiltersView(vm: vm)
}

View File

@@ -86,6 +86,11 @@ struct HomeView: View {
.refreshable { await vm.load() }
.task { await vm.load() }
.errorAlert($vm.error)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
AvatarToolbarButton()
}
}
}
}
}

View File

@@ -220,6 +220,11 @@ struct LibraryView: View {
.refreshable { await vm.load() }
.task { await vm.load() }
.errorAlert($vm.error)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
AvatarToolbarButton()
}
}
}
}

View File

@@ -0,0 +1,341 @@
import SwiftUI
import PhotosUI
import Kingfisher
// MARK: - AvatarNavButton
// Drop this into any NavigationStack toolbar to get an avatar button that opens the account sheet.
//
// Usage:
// .toolbar { AvatarToolbarButton() }
struct AvatarToolbarButton: View {
@EnvironmentObject private var authStore: AuthStore
@State private var showAccount = false
var body: some View {
Button {
showAccount = true
} label: {
AvatarThumb(urlString: authStore.user?.avatarURL, size: 30)
}
.sheet(isPresented: $showAccount) {
AccountMenuSheet()
}
}
}
// MARK: - AvatarThumb
// Reusable small circular avatar (used by both toolbar button and the sheet header).
struct AvatarThumb: View {
let urlString: String?
let size: CGFloat
var body: some View {
Group {
if let str = urlString, let url = URL(string: str) {
KFImage(url)
.placeholder { placeholderCircle }
.resizable()
.scaledToFill()
} else {
placeholderCircle
}
}
.frame(width: size, height: size)
.clipShape(Circle())
.overlay(Circle().stroke(Color.amber.opacity(0.6), lineWidth: 1.5))
}
private var placeholderCircle: some View {
Circle()
.fill(Color(.systemGray4))
.overlay(
Image(systemName: "person.fill")
.font(.system(size: size * 0.5))
.foregroundStyle(Color.amber)
)
}
}
// MARK: - AccountMenuSheet
struct AccountMenuSheet: View {
@EnvironmentObject private var authStore: AuthStore
@StateObject private var vm = ProfileViewModel()
@Environment(\.dismiss) private var dismiss
@State private var showChangePassword = false
// Avatar upload
@State private var photoPickerItem: PhotosPickerItem?
@State private var pendingCropImage: UIImage?
@State private var avatarURL: String? = nil
@State private var avatarUploading = false
@State private var avatarError: String?
var body: some View {
NavigationStack {
List {
// User header
Section {
HStack(spacing: 16) {
avatarPicker
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") {
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) {
dismiss()
Task { await authStore.logout() }
}
}
}
.navigationTitle("Account")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
.fontWeight(.semibold)
}
}
.task { await vm.loadSessions() }
.sheet(isPresented: $showChangePassword) {
ChangePasswordView()
}
.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($vm.error)
}
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
// 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")
avatarURL = url
await authStore.validateToken()
} catch {
avatarError = "Upload failed: \(error.localizedDescription)"
}
}
// MARK: - Avatar picker
@ViewBuilder
private var avatarPicker: some View {
PhotosPicker(selection: $photoPickerItem,
matching: .images,
photoLibrary: .shared()) {
ZStack {
Circle()
.fill(Color(.systemGray5))
.frame(width: 72, height: 72)
if avatarUploading {
ProgressView()
.frame(width: 72, height: 72)
} else if let urlStr = avatarURL ?? authStore.user?.avatarURL,
let url = URL(string: urlStr) {
KFImage(url)
.placeholder {
Image(systemName: "person.circle.fill")
.font(.system(size: 52))
.foregroundStyle(.amber)
}
.resizable()
.scaledToFill()
.frame(width: 72, height: 72)
.clipShape(Circle())
} else {
Image(systemName: "person.circle.fill")
.font(.system(size: 52))
.foregroundStyle(.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)
.onChange(of: photoPickerItem) { _, item in
guard let item else { return }
Task { await loadImageForCrop(item) }
}
}
// 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...2.0, step: 0.25
)
.tint(.amber)
}
}
}
// MARK: - Session row (local copy mirrors ProfileView.SessionRow)
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: - CropImageItem (Identifiable wrapper for the sheet)
private struct CropImageItem: Identifiable {
let id = UUID()
let image: UIImage
}

View File

@@ -0,0 +1,249 @@
import SwiftUI
// MARK: - SearchView
// Dedicated search tab modelled after Apple Books' Search screen.
// Shows a prominent search bar; while idle displays recent searches and
// trending/popular novels; after a query shows a results grid.
struct SearchView: View {
@StateObject private var vm = SearchViewModel()
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search bar
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search novels, authors…", text: $vm.query)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.search)
.onSubmit { vm.submitSearch() }
if !vm.query.isEmpty {
Button { vm.clear() } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
}
.padding(10)
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 12)
Divider()
// Content
if vm.query.isEmpty && vm.results.isEmpty {
idleContent
} else if vm.isLoading {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if vm.results.isEmpty {
EmptyStateView(
icon: "magnifyingglass",
title: "No results",
message: "Try a different title or author name."
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
resultsGrid
}
}
.navigationTitle("Search")
.appNavigationDestination()
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
AvatarToolbarButton()
}
}
}
}
// MARK: - Idle screen (recent searches + popular)
@ViewBuilder
private var idleContent: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
// Recent searches
if !vm.recentSearches.isEmpty {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Recent")
.font(.title3.bold())
Spacer()
Button("Clear") { vm.clearRecent() }
.font(.subheadline)
.foregroundStyle(.amber)
}
.padding(.horizontal)
.padding(.bottom, 10)
ForEach(vm.recentSearches, id: \.self) { term in
Button {
vm.query = term
vm.submitSearch()
} label: {
HStack {
Image(systemName: "clock")
.foregroundStyle(.secondary)
.frame(width: 20)
Text(term)
.foregroundStyle(.primary)
Spacer()
Image(systemName: "arrow.up.left")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal)
.padding(.vertical, 11)
}
Divider().padding(.leading, 44)
}
}
}
// Popular / trending novels (loaded from browse popular)
if !vm.popular.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text("Popular")
.font(.title3.bold())
.padding(.horizontal)
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12)
],
spacing: 16
) {
ForEach(vm.popular) { novel in
NavigationLink(value: NavDestination.book(novel.slug)) {
SearchNovelCard(novel: novel)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
}
}
Color.clear.frame(height: 20)
}
.padding(.top, 16)
}
}
// MARK: - Results grid
@ViewBuilder
private var resultsGrid: some View {
ScrollView {
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12)
],
spacing: 16
) {
ForEach(vm.results) { novel in
NavigationLink(value: NavDestination.book(novel.slug)) {
SearchNovelCard(novel: novel)
}
.buttonStyle(.plain)
}
}
.padding()
}
}
}
// MARK: - Search novel card (compact 3-column)
private struct SearchNovelCard: View {
let novel: BrowseNovel
var body: some View {
VStack(alignment: .leading, spacing: 6) {
AsyncCoverImage(url: novel.cover)
.frame(maxWidth: .infinity)
.aspectRatio(2/3, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 8))
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
.bookCoverZoomSource(slug: novel.slug)
Text(novel.title)
.font(.caption.bold())
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// MARK: - SearchViewModel
@MainActor
final class SearchViewModel: ObservableObject {
@Published var query: String = ""
@Published var results: [BrowseNovel] = []
@Published var popular: [BrowseNovel] = []
@Published var isLoading = false
// Persisted in UserDefaults (max 10 recent terms)
@Published var recentSearches: [String] = []
private let recentKey = "searchRecentTerms"
init() {
recentSearches = (UserDefaults.standard.stringArray(forKey: recentKey) ?? [])
Task { await loadPopular() }
}
func submitSearch() {
let term = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !term.isEmpty else { return }
saveRecent(term)
Task { await runSearch(term) }
}
func clear() {
query = ""
results = []
}
func clearRecent() {
recentSearches = []
UserDefaults.standard.removeObject(forKey: recentKey)
}
private func runSearch(_ term: String) async {
isLoading = true
do {
let result = try await APIClient.shared.search(query: term)
results = result.results
} catch {
results = []
}
isLoading = false
}
private func loadPopular() async {
do {
let result = try await APIClient.shared.browse(page: 1, genre: "all", sort: "popular", status: "all")
popular = Array(result.novels.prefix(12))
} catch {}
}
private func saveRecent(_ term: String) {
var list = recentSearches.filter { $0 != term }
list.insert(term, at: 0)
if list.count > 10 { list = Array(list.prefix(10)) }
recentSearches = list
UserDefaults.standard.set(list, forKey: recentKey)
}
}