From 7881f832a63db31c9d08752579f1e4c6281048a2 Mon Sep 17 00:00:00 2001 From: Lee Jun Kit Date: Wed, 23 Jun 2021 16:48:29 +0800 Subject: [PATCH] wip implement search --- Spottie/Backend/HTTPClient.swift | 21 ++++++- Spottie/Backend/SpotifyAPI.swift | 8 +-- .../Types/API/SearchResultsResponse.swift | 2 +- .../Types/WebAPI/WebAPIPagingObject.swift | 4 +- Spottie/Views/ContentView.swift | 7 --- Spottie/Views/Home.swift | 61 +++++++++++++------ Spottie/Views/Search.swift | 32 +++++++++- Spottie/Views/Sidebar.swift | 11 +--- 8 files changed, 99 insertions(+), 47 deletions(-) diff --git a/Spottie/Backend/HTTPClient.swift b/Spottie/Backend/HTTPClient.swift index dfbad34..58f0973 100644 --- a/Spottie/Backend/HTTPClient.swift +++ b/Spottie/Backend/HTTPClient.swift @@ -9,8 +9,13 @@ import Foundation import Combine struct HTTPClient { + enum APIError: Error { + case unknown + case contentTypeError + } + struct Response { - let value: T? + let value: T let response: URLResponse } @@ -18,6 +23,10 @@ struct HTTPClient { return URLSession.shared .dataTaskPublisher(for: request) .tryMap { result -> Response in + if T.self == Nothing.self { + return Response(value: Nothing() as! T, response: result.response) + } + let response = result.response as! HTTPURLResponse let contentType = response.allHeaderFields["Content-Type"] as? String @@ -27,10 +36,16 @@ struct HTTPClient { return Response(value: value, response: result.response) } } - - return Response(value: nil, response: result.response) + + throw APIError.contentTypeError } .receive(on: DispatchQueue.main) .eraseToAnyPublisher() } + + func runWithoutDecoding(_ request: URLRequest) -> AnyPublisher { + return URLSession.shared.dataTaskPublisher(for: request) + .ignoreOutput() + .eraseToAnyPublisher() + } } diff --git a/Spottie/Backend/SpotifyAPI.swift b/Spottie/Backend/SpotifyAPI.swift index d30d640..cdae9c3 100644 --- a/Spottie/Backend/SpotifyAPI.swift +++ b/Spottie/Backend/SpotifyAPI.swift @@ -105,7 +105,7 @@ extension SpotifyAPI { return client.run(req).map(\.value).eraseToAnyPublisher() } - static func getPersonalizedRecommendations() -> AnyPublisher { + static func getPersonalizedRecommendations() -> AnyPublisher { // get the current date let date = Date() let formatter = ISO8601DateFormatter() @@ -137,9 +137,9 @@ extension SpotifyAPI { return client.run(req, decoder).map(\.value).eraseToAnyPublisher() } - static func search() -> AnyPublisher { + static func search(_ term: String) -> AnyPublisher { let queryItems = [ - URLQueryItem(name: "q", value: "akmu"), + URLQueryItem(name: "q", value: term), URLQueryItem(name: "type", value: "album,artist,playlist,track"), URLQueryItem(name: "market", value: "from_token") ] @@ -155,6 +155,6 @@ extension SpotifyAPI { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase; - return client.run(req, decoder).map(\.value).eraseToAnyPublisher() + return client.run(req, decoder).map(\.value).print().eraseToAnyPublisher() } } diff --git a/Spottie/Backend/Types/API/SearchResultsResponse.swift b/Spottie/Backend/Types/API/SearchResultsResponse.swift index f70ae4d..e8d135f 100644 --- a/Spottie/Backend/Types/API/SearchResultsResponse.swift +++ b/Spottie/Backend/Types/API/SearchResultsResponse.swift @@ -9,7 +9,7 @@ import Foundation struct SearchResultsResponse: Decodable { let albums: WebAPIPagingObject - let artists: WebAPIPagingObject + let artists: WebAPIPagingObject let tracks: WebAPIPagingObject let playlists: WebAPIPagingObject } diff --git a/Spottie/Backend/Types/WebAPI/WebAPIPagingObject.swift b/Spottie/Backend/Types/WebAPI/WebAPIPagingObject.swift index 0c61a5c..7a56bcc 100644 --- a/Spottie/Backend/Types/WebAPI/WebAPIPagingObject.swift +++ b/Spottie/Backend/Types/WebAPI/WebAPIPagingObject.swift @@ -13,6 +13,6 @@ struct WebAPIPagingObject: Decodable { let limit: Int let offset: Int let total: Int - let next: String - let previous: String + let next: String? + let previous: String? } diff --git a/Spottie/Views/ContentView.swift b/Spottie/Views/ContentView.swift index 116c32e..1fa0d41 100644 --- a/Spottie/Views/ContentView.swift +++ b/Spottie/Views/ContentView.swift @@ -13,7 +13,6 @@ enum Screen: Hashable { struct ContentView: View { @State var screen: Screen? = .home - @State var searchText = "" var body: some View { VStack { @@ -25,12 +24,6 @@ struct ContentView: View { .frame(height: 66) .padding() } - .toolbar { - ToolbarItem { - SearchField(search: $searchText) - .frame(minWidth: 100, idealWidth: 200, maxWidth: .infinity) - } - } } } diff --git a/Spottie/Views/Home.swift b/Spottie/Views/Home.swift index 78583a3..ad89ae6 100644 --- a/Spottie/Views/Home.swift +++ b/Spottie/Views/Home.swift @@ -10,32 +10,57 @@ import Combine struct Home: View { @ObservedObject var viewModel: ViewModel = ViewModel() + + // search + @State private var searchText = "" + private var relay = PassthroughSubject() + private var debouncedPublisher: AnyPublisher + + init() { + self.debouncedPublisher = relay + .debounce(for: 0.5, scheduler: DispatchQueue.main) + .eraseToAnyPublisher() + } + var body: some View { GeometryReader { reader in ScrollView(.vertical) { - LazyVStack(alignment: .leading) { - ForEach(viewModel.recommendationGroups) { group in - if group.id == "shortcuts" { - ShortcutGrid( - items: group.items, - onItemPressed: viewModel.load - ) - .padding() - } else if group.items.count > 0 { - let vm = CarouselRow.ViewModel.init(group, - numberOfItemsToShow: numberOfItemsToShowInRow(reader) - ) - - CarouselRow( - viewModel: vm, - onItemPressed: viewModel.load - ) - .padding() + if searchText.isEmpty { + LazyVStack(alignment: .leading) { + ForEach(viewModel.recommendationGroups) { group in + if group.id == "shortcuts" { + ShortcutGrid( + items: group.items, + onItemPressed: viewModel.load + ) + .padding() + } else if group.items.count > 0 { + let vm = CarouselRow.ViewModel.init(group, + numberOfItemsToShow: numberOfItemsToShowInRow(reader) + ) + + CarouselRow( + viewModel: vm, + onItemPressed: viewModel.load + ) + .padding() + } } } + } else { + Search(viewModel: Search.ViewModel(searchTermPublisher: debouncedPublisher)) } } } + .toolbar { + ToolbarItem { + SearchField(search: $searchText) + .frame(minWidth: 100, idealWidth: 200, maxWidth: .infinity) + } + } + .onChange(of: searchText) { text in + relay.send(text) + } } func numberOfItemsToShowInRow(_ reader: GeometryProxy) -> Int { diff --git a/Spottie/Views/Search.swift b/Spottie/Views/Search.swift index 346bb6c..721129f 100644 --- a/Spottie/Views/Search.swift +++ b/Spottie/Views/Search.swift @@ -6,15 +6,43 @@ // import SwiftUI +import Combine struct Search: View { + @StateObject var viewModel: ViewModel var body: some View { - Text("Hello Search") + ScrollView { + LazyVStack(alignment: .leading) { + + } + } + } +} + +extension Search { + // The trick to get updates as the NSSearchField is updated + // is to create a publisher from onChange of searchText + // https://rhonabwy.com/2021/02/07/integrating-swiftui-bindings-and-combine/ + class ViewModel: ObservableObject { + @Published var results: SearchResultsResponse? + + init(searchTermPublisher: AnyPublisher) { + searchTermPublisher + .debounce(for: 0.5, scheduler: DispatchQueue.main) + .map { + SpotifyAPI.search($0) + .replaceError(with: nil) + } + .switchToLatest() + .receive(on: DispatchQueue.main) + .assign(to: &$results) + } } } struct Search_Previews: PreviewProvider { + static let vm = Search.ViewModel(searchTermPublisher: Just("Hello").eraseToAnyPublisher()) static var previews: some View { - Search() + Search(viewModel: vm) } } diff --git a/Spottie/Views/Sidebar.swift b/Spottie/Views/Sidebar.swift index 3895ca3..9917e7a 100644 --- a/Spottie/Views/Sidebar.swift +++ b/Spottie/Views/Sidebar.swift @@ -17,15 +17,7 @@ struct Sidebar: View { tag: Screen.home, selection: $state, label: { - Label("Home", systemImage: "house" ) - } - ) - NavigationLink( - destination: Search(), - tag: Screen.search, - selection: $state, - label: { - Label("Search", systemImage: "magnifyingglass") + Label("Home", systemImage: "house") } ) NavigationLink( @@ -38,7 +30,6 @@ struct Sidebar: View { ) } .listStyle(SidebarListStyle()) - .navigationTitle("Spottie") } }