From 4d3c093612831c8ec2b14d72a5b0de4f663691bf Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 10 Mar 2026 21:30:28 +0500 Subject: [PATCH] feat(ios): replace profile tab with search tab, add avatar button opening account sheet --- ios/LibNovel/LibNovel/App/RootTabView.swift | 8 +- .../LibNovel/Views/Browse/BrowseView.swift | 5 + .../LibNovel/Views/Home/HomeView.swift | 5 + .../LibNovel/Views/Library/LibraryView.swift | 5 + .../Views/Profile/AccountMenuSheet.swift | 341 ++++++++++++++++++ .../LibNovel/Views/Search/SearchView.swift | 249 +++++++++++++ 6 files changed, 609 insertions(+), 4 deletions(-) create mode 100644 ios/LibNovel/LibNovel/Views/Profile/AccountMenuSheet.swift create mode 100644 ios/LibNovel/LibNovel/Views/Search/SearchView.swift diff --git a/ios/LibNovel/LibNovel/App/RootTabView.swift b/ios/LibNovel/LibNovel/App/RootTabView.swift index 56ba759..c3c374f 100644 --- a/ios/LibNovel/LibNovel/App/RootTabView.swift +++ b/ios/LibNovel/LibNovel/App/RootTabView.swift @@ -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. diff --git a/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift b/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift index c2b150b..3189545 100644 --- a/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift +++ b/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift @@ -92,6 +92,11 @@ struct BrowseView: View { } .navigationTitle("Discover") .appNavigationDestination() + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + AvatarToolbarButton() + } + } .sheet(isPresented: $showFilters) { BrowseFiltersView(vm: vm) } diff --git a/ios/LibNovel/LibNovel/Views/Home/HomeView.swift b/ios/LibNovel/LibNovel/Views/Home/HomeView.swift index 96c0e07..5454e9a 100644 --- a/ios/LibNovel/LibNovel/Views/Home/HomeView.swift +++ b/ios/LibNovel/LibNovel/Views/Home/HomeView.swift @@ -86,6 +86,11 @@ struct HomeView: View { .refreshable { await vm.load() } .task { await vm.load() } .errorAlert($vm.error) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + AvatarToolbarButton() + } + } } } } diff --git a/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift b/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift index 123d8d7..b13930a 100644 --- a/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift +++ b/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift @@ -220,6 +220,11 @@ struct LibraryView: View { .refreshable { await vm.load() } .task { await vm.load() } .errorAlert($vm.error) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + AvatarToolbarButton() + } + } } } diff --git a/ios/LibNovel/LibNovel/Views/Profile/AccountMenuSheet.swift b/ios/LibNovel/LibNovel/Views/Profile/AccountMenuSheet.swift new file mode 100644 index 0000000..79aff03 --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Profile/AccountMenuSheet.swift @@ -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 +} diff --git a/ios/LibNovel/LibNovel/Views/Search/SearchView.swift b/ios/LibNovel/LibNovel/Views/Search/SearchView.swift new file mode 100644 index 0000000..6d7c50e --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Search/SearchView.swift @@ -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) + } +}