diff --git a/Model/Recents.swift b/Model/Recents.swift index d46edb59..ccc99f19 100644 --- a/Model/Recents.swift +++ b/Model/Recents.swift @@ -79,6 +79,12 @@ struct RecentItem: Defaults.Serializable, Identifiable { id = channel.id title = channel.name } + + init(from query: String) { + type = .query + id = query + title = query + } } struct RecentItemBridge: Defaults.Bridge { diff --git a/Model/SearchState.swift b/Model/SearchState.swift index f1664b79..2149aba1 100644 --- a/Model/SearchState.swift +++ b/Model/SearchState.swift @@ -6,6 +6,8 @@ final class SearchState: ObservableObject { @Published var store = Store<[Video]>() @Published var query = SearchQuery() + @Published var queryText = "" + @Published var querySuggestions = Store<[String]>() private var previousResource: Resource? diff --git a/Shared/EnvironmentValues.swift b/Shared/EnvironmentValues.swift index 67cd6dd1..1b296555 100644 --- a/Shared/EnvironmentValues.swift +++ b/Shared/EnvironmentValues.swift @@ -9,6 +9,14 @@ private struct HorizontalCellsKey: EnvironmentKey { static let defaultValue = false } +enum NavigationStyle { + case tab, sidebar +} + +private struct NavigationStyleKey: EnvironmentKey { + static let defaultValue = NavigationStyle.tab +} + extension EnvironmentValues { var inNavigationView: Bool { get { self[InNavigationViewKey.self] } @@ -19,4 +27,9 @@ extension EnvironmentValues { get { self[HorizontalCellsKey.self] } set { self[HorizontalCellsKey.self] = newValue } } + + var navigationStyle: NavigationStyle { + get { self[NavigationStyleKey.self] } + set { self[NavigationStyleKey.self] = newValue } + } } diff --git a/Shared/Navigation/AppSidebarNavigation.swift b/Shared/Navigation/AppSidebarNavigation.swift index 468d4998..4bb0f1cc 100644 --- a/Shared/Navigation/AppSidebarNavigation.swift +++ b/Shared/Navigation/AppSidebarNavigation.swift @@ -20,8 +20,6 @@ struct AppSidebarNavigation: View { @State private var didApplyPrimaryViewWorkAround = false - @State private var searchQuery = "" - var selection: Binding { navigationState.tabSelectionOptionalBinding } @@ -53,21 +51,22 @@ struct AppSidebarNavigation: View { Text("Select section") } - .searchable(text: $searchQuery, placement: .sidebar) { + .environment(\.navigationStyle, .sidebar) + .searchable(text: $searchState.queryText, placement: .sidebar) { ForEach(searchState.querySuggestions.collection, id: \.self) { suggestion in Text(suggestion) .searchCompletion(suggestion) } } - .onChange(of: searchQuery) { query in + .onChange(of: searchState.queryText) { query in searchState.loadQuerySuggestions(query) } .onSubmit(of: .search) { searchState.changeQuery { query in - query.query = self.searchQuery + query.query = searchState.queryText } - recents.open(RecentItem(type: .query, identifier: self.searchQuery, title: self.searchQuery)) + recents.open(RecentItem(from: searchState.queryText)) navigationState.tabSelection = .search } diff --git a/Shared/Navigation/AppSidebarRecents.swift b/Shared/Navigation/AppSidebarRecents.swift index dd254283..1a919298 100644 --- a/Shared/Navigation/AppSidebarRecents.swift +++ b/Shared/Navigation/AppSidebarRecents.swift @@ -13,7 +13,7 @@ struct AppSidebarRecents: View { Group { if !recentItems.isEmpty { Section(header: Text("Recents")) { - ForEach(recentItems) { recent in + ForEach(recentItems.reversed()) { recent in Group { switch recent.type { case .channel: diff --git a/Shared/Navigation/AppTabNavigation.swift b/Shared/Navigation/AppTabNavigation.swift index 1fa03a53..cceaa1ca 100644 --- a/Shared/Navigation/AppTabNavigation.swift +++ b/Shared/Navigation/AppTabNavigation.swift @@ -4,11 +4,8 @@ import SwiftUI struct AppTabNavigation: View { @EnvironmentObject private var navigationState @EnvironmentObject private var searchState - @EnvironmentObject private var recents - @State private var searchQuery = "" - var body: some View { TabView(selection: $navigationState.tabSelection) { NavigationView { @@ -60,20 +57,22 @@ struct AppTabNavigation: View { NavigationView { SearchView() - .searchable(text: $searchQuery, placement: .navigationBarDrawer(displayMode: .always)) { + .searchable(text: $searchState.queryText, placement: .navigationBarDrawer(displayMode: .always)) { ForEach(searchState.querySuggestions.collection, id: \.self) { suggestion in Text(suggestion) .searchCompletion(suggestion) } } - .onChange(of: searchQuery) { query in + .onChange(of: searchState.queryText) { query in searchState.loadQuerySuggestions(query) } .onSubmit(of: .search) { searchState.changeQuery { query in - query.query = self.searchQuery + query.query = searchState.queryText } + recents.open(RecentItem(from: searchState.queryText)) + navigationState.tabSelection = .search } } @@ -83,6 +82,7 @@ struct AppTabNavigation: View { } .tag(TabSelection.search) } + .environment(\.navigationStyle, .tab) .sheet(isPresented: $navigationState.isChannelOpen, onDismiss: { if let channel = recents.presentedChannel { let recent = RecentItem(from: channel) diff --git a/Shared/Views/SearchView.swift b/Shared/Views/SearchView.swift index f56334f1..0c475ee7 100644 --- a/Shared/Views/SearchView.swift +++ b/Shared/Views/SearchView.swift @@ -7,8 +7,14 @@ struct SearchView: View { @Default(.searchDate) private var searchDate @Default(.searchDuration) private var searchDuration + @EnvironmentObject private var recents @EnvironmentObject private var state + @Environment(\.navigationStyle) private var navigationStyle + + @State private var presentingClearConfirmation = false + @State private var recentsChanged = false + private var query: SearchQuery? init(_ query: SearchQuery? = nil) { @@ -16,23 +22,34 @@ struct SearchView: View { } var body: some View { - VStack { - VideosView(videos: state.store.collection) - - if state.store.collection.isEmpty && !state.isLoading && !state.query.isEmpty { - Text("No results") - - if searchFiltersActive { - Button("Reset search filters") { - Defaults.reset(.searchDate, .searchDuration) + Group { + if navigationStyle == .tab && state.queryText.isEmpty { + VStack { + if !recentItems.isEmpty { + recentQueries } } + } else { + VideosView(videos: state.store.collection) - Spacer() + if state.store.collection.isEmpty && !state.isLoading && !state.query.isEmpty { + Text("No results") + + if searchFiltersActive { + Button("Reset search filters") { + Defaults.reset(.searchDate, .searchDuration) + } + } + + Spacer() + } } } .onAppear { if query != nil { + if navigationStyle == .tab { + state.queryText = query!.query + } state.resetQuery(query!) } } @@ -53,11 +70,63 @@ struct SearchView: View { #endif } + var recentQueries: some View { + List { + Section(header: Text("Recents")) { + ForEach(recentItems) { item in + Button(item.title) { + state.queryText = item.title + state.changeQuery { query in query.query = item.title } + } + #if os(iOS) + .swipeActions(edge: .trailing) { + clearButton(item) + } + #endif + } + } + .opacity(recentsChanged ? 1 : 1) + + clearAllButton + } + #if os(iOS) + .listStyle(.insetGrouped) + #endif + } + + func clearButton(_ item: RecentItem) -> some View { + Button(role: .destructive) { + recents.close(item) + recentsChanged.toggle() + } label: { + Label("Delete", systemImage: "trash") + } + } + + var clearAllButton: some View { + Button("Clear All", role: .destructive) { + presentingClearConfirmation = true + } + .confirmationDialog("Clear All", isPresented: $presentingClearConfirmation) { + Button("Clear All", role: .destructive) { + recents.clearQueries() + } + } + } + var navigationTitle: String { - state.query.query.isEmpty ? "Search" : "Search: \"\(state.query.query)\"" + if state.query.query.isEmpty || (navigationStyle == .tab && state.queryText.isEmpty) { + return "Search" + } + + return "Search: \"\(state.query.query)\"" } var searchFiltersActive: Bool { searchDate != nil || searchDuration != nil } + + var recentItems: [RecentItem] { + Defaults[.recentlyOpened].filter { $0.type == .query }.reversed() + } } diff --git a/tvOS/TVNavigationView.swift b/tvOS/TVNavigationView.swift index 508ee532..1faa5a3d 100644 --- a/tvOS/TVNavigationView.swift +++ b/tvOS/TVNavigationView.swift @@ -34,14 +34,15 @@ struct TVNavigationView: View { .tag(TabSelection.playlists) SearchView() - .searchable(text: $searchState.query.query) { + .searchable(text: $searchState.queryText) { ForEach(searchState.querySuggestions.collection, id: \.self) { suggestion in Text(suggestion) .searchCompletion(suggestion) } } - .onChange(of: searchState.query.query) { query in - searchState.loadQuerySuggestions(query) + .onChange(of: searchState.queryText) { newQuery in + searchState.loadQuerySuggestions(newQuery) + searchState.changeQuery { query in query.query = newQuery } } .tabItem { Image(systemName: "magnifyingglass") } .tag(TabSelection.search)