diff --git a/apple/OmnivoreKit/Sources/App/Services.swift b/apple/OmnivoreKit/Sources/App/Services.swift index bad6a6fea..10f0a0467 100644 --- a/apple/OmnivoreKit/Sources/App/Services.swift +++ b/apple/OmnivoreKit/Sources/App/Services.swift @@ -18,7 +18,7 @@ public final class Services { let networker = Networker(appEnvironment: appEnvironment) self.authenticator = Authenticator(networker: networker) self.dataService = DataService(appEnvironment: appEnvironment, networker: networker) - self.audioController = AudioController(appEnvironment: appEnvironment, networker: networker) + self.audioController = AudioController(dataService: dataService) } } diff --git a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift index f4ccce43b..69f1b45de 100644 --- a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift @@ -155,224 +155,238 @@ public struct MiniPlayer: View { } } - // swiftlint:disable:next function_body_length - func playerContent(_ itemAudioProperties: LinkedItemAudioProperties) -> some View { - GeometryReader { geom in - VStack(spacing: 0) { - if expanded { - ZStack { - closeButton - .padding(.top, 24) - .padding(.leading, 16) - .frame(maxWidth: .infinity, alignment: .leading) - - Capsule() - .fill(.gray) - .frame(width: 60, height: 4) - .padding(.top, 8) - .transition(.opacity) - } - } else { - HStack(alignment: .center, spacing: 8) { - let dim = 64.0 - - if let imageURL = itemAudioProperties.imageURL { - AsyncImage(url: imageURL) { phase in - if let image = phase.image { - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: dim, height: dim) - .cornerRadius(6) - } else if phase.error != nil { - defaultArtwork(forDimensions: dim) - } else { - Color.appButtonBackground - .frame(width: dim, height: dim) - .cornerRadius(6) - } - } - } else { - defaultArtwork(forDimensions: dim) - } - - VStack { - Text(itemAudioProperties.title) - .font(.appCallout) - .foregroundColor(.appGrayTextContrast) - .fixedSize(horizontal: false, vertical: false) - .frame(maxWidth: .infinity, alignment: .leading) - - if let byline = itemAudioProperties.byline { - Text(byline) - .font(.appCaption) - .lineSpacing(1.25) - .foregroundColor(.appGrayText) - .fixedSize(horizontal: false, vertical: false) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - - playPauseButtonItem - .frame(width: 28, height: 28) - - stopButton - .frame(width: 28, height: 28) - } - .padding(16) - .frame(maxHeight: .infinity) - } - - if expanded { - ZStack { - TabView(selection: $tabIndex) { - ForEach(0 ..< (self.audioController.textItems?.count ?? 0), id: \.self) { id in - SpeechCard(id: id) - .frame(width: geom.size.width) - .tag(id) - } - } - .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) - .onChange(of: tabIndex, perform: { index in - if index != audioController.currentAudioIndex, index < (audioController.textItems?.count ?? 0) { - audioController.seek(toUtterance: index) - } - }) - .onChange(of: audioController.currentAudioIndex, perform: { index in - if self.audioController.state != .reachedEnd { - tabIndex = index - } else { - tabIndex = (self.audioController.textItems?.count ?? 0) + 1 - } - }) - .frame(width: geom.size.width) - - if audioController.state == .reachedEnd { - // If we have reached the end display a replay button - Button( - action: { - tabIndex = 0 - audioController.unpause() - audioController.seek(to: 0.0) - }, - label: { - Image(systemName: "gobackward") - .font(.appCallout) - .tint(.appGrayTextContrast) - Text("Replay") - } - ) - } - } - - Spacer() - - Group { - ScrubberView(value: $audioController.timeElapsed, - minValue: 0, maxValue: self.audioController.duration, - onEditingChanged: { scrubStarted in - if scrubStarted { - self.audioController.scrubState = .scrubStarted - } else { - self.audioController.scrubState = .scrubEnded(self.audioController.timeElapsed) - } - }) - - HStack { - Text(audioController.timeElapsedString ?? "0:00") - .font(.appCaptionTwo) - .foregroundColor(.appGrayText) - Spacer() - Text(audioController.durationString ?? "0:00") - .font(.appCaptionTwo) - .foregroundColor(.appGrayText) - } - } - .padding(.leading, 16) - .padding(.trailing, 16) - - HStack(alignment: .center, spacing: 36) { - Menu { - playbackRateButton(rate: 1.0, title: "1.0×", selected: audioController.playbackRate == 1.0) - playbackRateButton(rate: 1.1, title: "1.1×", selected: audioController.playbackRate == 1.1) - playbackRateButton(rate: 1.2, title: "1.2×", selected: audioController.playbackRate == 1.2) - playbackRateButton(rate: 1.5, title: "1.5×", selected: audioController.playbackRate == 1.5) - playbackRateButton(rate: 1.7, title: "1.7×", selected: audioController.playbackRate == 1.7) - playbackRateButton(rate: 2.0, title: "2.0×", selected: audioController.playbackRate == 2.0) - } label: { - VStack { - Text(String(format: "%.1f×", audioController.playbackRate)) - .font(.appCallout) - .lineLimit(0) - } - .contentShape(Rectangle()) - } - .padding(8) - - Button( - action: { self.audioController.skipBackwards(seconds: 30) }, - label: { - Image(systemName: "gobackward.30") - .font(.appTitleTwo) - } - ) - - playPauseButtonItem - .frame(width: 56, height: 56) - - Button( - action: { self.audioController.skipForward(seconds: 30) }, - label: { - Image(systemName: "goforward.30") - .font(.appTitleTwo) - } - ) - - Menu { - Button("View Article", action: { viewArticle() }) - Button("Change Voice", action: { showVoiceSheet = true }) - } label: { - VStack { - Image(systemName: "ellipsis") - .font(.appCallout) - .frame(width: 20, height: 20) - } - .contentShape(Rectangle()) - } - .padding(8) - }.padding(.bottom, 16) + var audioCards: some View { + ZStack { + let textItems = self.audioController.textItems ?? [] + TabView(selection: $tabIndex) { + ForEach(0 ..< textItems.count, id: \.self) { id in + SpeechCard(id: id) + .tag(id) } } - .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - .background( - Color.systemBackground - .shadow(color: expanded ? .clear : .gray.opacity(0.33), radius: 8, x: 0, y: 4) - .mask(Rectangle().padding(.top, -20)) - ) - .onTapGesture { - withAnimation(.easeIn(duration: 0.08)) { expanded = true } - }.sheet(isPresented: $showVoiceSheet) { - NavigationView { - TextToSpeechVoiceSelectionView(forLanguage: audioController.currentVoiceLanguage, showLanguageChanger: true) - .navigationBarTitle("Voice") - .navigationBarTitleDisplayMode(.inline) - .navigationBarItems(leading: Button(action: { self.showVoiceSheet = false }) { - Image(systemName: "chevron.backward") - .font(.appNavbarIcon) - .tint(.appGrayTextContrast) - }) + .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) + .onChange(of: tabIndex, perform: { index in + if index != audioController.currentAudioIndex, index < (audioController.textItems?.count ?? 0) { + audioController.seek(toUtterance: index) } - }.sheet(isPresented: $showLanguageSheet) { - NavigationView { - TextToSpeechLanguageView() - .navigationBarTitle("Language") - .navigationBarTitleDisplayMode(.inline) - .navigationBarItems(leading: Button(action: { self.showLanguageSheet = false }) { - Image(systemName: "chevron.backward") - .font(.appNavbarIcon) - .tint(.appGrayTextContrast) - }) + }) + .onChange(of: audioController.currentAudioIndex, perform: { index in + if index >= textItems.count { + return } + + if self.audioController.state != .reachedEnd { + tabIndex = index + } + }) + + if audioController.state == .reachedEnd { + // If we have reached the end display a replay button with an overlay behind + Color.systemBackground.opacity(0.85) + .frame( + minWidth: 0, + maxWidth: .infinity, + minHeight: 0, + maxHeight: .infinity, + alignment: .topLeading + ) + + Button( + action: { + tabIndex = 0 + audioController.unpause() + audioController.seek(to: 0.0) + }, + label: { + HStack { + Image(systemName: "gobackward") + .font(.appCallout) + .tint(.appGrayTextContrast) + Text("Replay") + } + } + ).buttonStyle(RoundedRectButtonStyle()) + } + } + } + + // swiftlint:disable:next function_body_length + func playerContent(_ itemAudioProperties: LinkedItemAudioProperties) -> some View { + VStack(spacing: 0) { + if expanded { + ZStack { + closeButton + .padding(.top, 24) + .padding(.leading, 16) + .frame(maxWidth: .infinity, alignment: .leading) + + Capsule() + .fill(.gray) + .frame(width: 60, height: 4) + .padding(.top, 8) + .transition(.opacity) + } + } else { + HStack(alignment: .center, spacing: 8) { + let dim = 64.0 + + if let imageURL = itemAudioProperties.imageURL { + AsyncImage(url: imageURL) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: dim, height: dim) + .cornerRadius(6) + } else if phase.error != nil { + defaultArtwork(forDimensions: dim) + } else { + Color.appButtonBackground + .frame(width: dim, height: dim) + .cornerRadius(6) + } + } + } else { + defaultArtwork(forDimensions: dim) + } + + VStack { + Text(itemAudioProperties.title) + .font(.appCallout) + .foregroundColor(.appGrayTextContrast) + .fixedSize(horizontal: false, vertical: false) + .frame(maxWidth: .infinity, alignment: .leading) + + if let byline = itemAudioProperties.byline { + Text(byline) + .font(.appCaption) + .lineSpacing(1.25) + .foregroundColor(.appGrayText) + .fixedSize(horizontal: false, vertical: false) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + playPauseButtonItem + .frame(width: 28, height: 28) + + stopButton + .frame(width: 28, height: 28) + } + .padding(16) + .frame(maxHeight: .infinity) + } + + if expanded { + audioCards + + Spacer() + + Group { + ScrubberView(value: $audioController.timeElapsed, + minValue: 0, maxValue: self.audioController.duration, + onEditingChanged: { scrubStarted in + if scrubStarted { + self.audioController.scrubState = .scrubStarted + } else { + self.audioController.scrubState = .scrubEnded(self.audioController.timeElapsed) + } + }) + + HStack { + Text(audioController.timeElapsedString ?? "0:00") + .font(.appCaptionTwo) + .foregroundColor(.appGrayText) + Spacer() + Text(audioController.durationString ?? "0:00") + .font(.appCaptionTwo) + .foregroundColor(.appGrayText) + } + } + .padding(.leading, 16) + .padding(.trailing, 16) + + HStack(alignment: .center, spacing: 36) { + Menu { + playbackRateButton(rate: 1.0, title: "1.0×", selected: audioController.playbackRate == 1.0) + playbackRateButton(rate: 1.1, title: "1.1×", selected: audioController.playbackRate == 1.1) + playbackRateButton(rate: 1.2, title: "1.2×", selected: audioController.playbackRate == 1.2) + playbackRateButton(rate: 1.5, title: "1.5×", selected: audioController.playbackRate == 1.5) + playbackRateButton(rate: 1.7, title: "1.7×", selected: audioController.playbackRate == 1.7) + playbackRateButton(rate: 2.0, title: "2.0×", selected: audioController.playbackRate == 2.0) + } label: { + VStack { + Text(String(format: "%.1f×", audioController.playbackRate)) + .font(.appCallout) + .lineLimit(0) + } + .contentShape(Rectangle()) + } + .padding(8) + + Button( + action: { self.audioController.skipBackwards(seconds: 30) }, + label: { + Image(systemName: "gobackward.30") + .font(.appTitleTwo) + } + ) + + playPauseButtonItem + .frame(width: 56, height: 56) + + Button( + action: { self.audioController.skipForward(seconds: 30) }, + label: { + Image(systemName: "goforward.30") + .font(.appTitleTwo) + } + ) + + Menu { + Button("View Article", action: { viewArticle() }) + Button("Change Voice", action: { showVoiceSheet = true }) + } label: { + VStack { + Image(systemName: "ellipsis") + .font(.appCallout) + .frame(width: 20, height: 20) + } + .contentShape(Rectangle()) + } + .padding(8) + }.padding(.bottom, 16) + } + } + .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .background( + Color.systemBackground + .shadow(color: expanded ? .clear : .gray.opacity(0.33), radius: 8, x: 0, y: 4) + .mask(Rectangle().padding(.top, -20)) + ) + .onTapGesture { + withAnimation(.easeIn(duration: 0.08)) { expanded = true } + }.sheet(isPresented: $showVoiceSheet) { + NavigationView { + TextToSpeechVoiceSelectionView(forLanguage: audioController.currentVoiceLanguage, showLanguageChanger: true) + .navigationBarTitle("Voice") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(leading: Button(action: { self.showVoiceSheet = false }, label: { + Image(systemName: "chevron.backward") + .font(.appNavbarIcon) + .tint(.appGrayTextContrast) + })) + } + }.sheet(isPresented: $showLanguageSheet) { + NavigationView { + TextToSpeechLanguageView() + .navigationBarTitle("Language") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(leading: Button(action: { self.showLanguageSheet = false }) { + Image(systemName: "chevron.backward") + .font(.appNavbarIcon) + .tint(.appGrayTextContrast) + }) } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 0154e4eff..e6a508038 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -12,6 +12,7 @@ import Views struct HomeFeedContainerView: View { @State var hasHighlightMutations = false + @State var searchPresented = false @EnvironmentObject var dataService: DataService @EnvironmentObject var audioController: AudioController @@ -66,7 +67,15 @@ import Views .sheet(item: $viewModel.itemForHighlightsView) { item in HighlightsListView(itemObjectID: item.objectID, hasHighlightMutations: $hasHighlightMutations) } + .navigationBarTitleDisplayMode(.inline) .toolbar { + ToolbarItem(placement: .barLeading) { + Image.smallOmnivoreLogo + .renderingMode(.template) + .resizable() + .frame(width: 24, height: 24) + .foregroundColor(.appGrayTextContrast) + } ToolbarItem(placement: .barTrailing) { Button("", action: {}) .disabled(true) @@ -88,15 +97,28 @@ import Views EmptyView() } } + ToolbarItem(placement: .barTrailing) { + Button( + action: { searchPresented = true }, + label: { + Image(systemName: "magnifyingglass") + .resizable() + .frame(width: 18, height: 18) + .padding(.vertical) + .foregroundColor(.appGrayTextContrast) + } + ) + } ToolbarItem(placement: .barTrailing) { if UIDevice.isIPhone { NavigationLink( destination: { ProfileView() }, label: { - Image.profile + Image(systemName: "person.circle") .resizable() - .frame(width: 26, height: 26) - .padding(.vertical) + .frame(width: 22, height: 22) + .padding(.vertical, 16) + .foregroundColor(.appGrayTextContrast) } ) } else { @@ -105,8 +127,6 @@ import Views } } } - .navigationTitle("Home") - .navigationBarTitleDisplayMode(.inline) .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in loadItems(isRefresh: true) } @@ -148,6 +168,9 @@ import Views } } } + .fullScreenCover(isPresented: $searchPresented) { + LibrarySearchView(homeFeedViewModel: self.viewModel) + } .task { if viewModel.items.isEmpty { loadItems(isRefresh: true) @@ -159,75 +182,23 @@ import Views struct HomeFeedView: View { @EnvironmentObject var dataService: DataService @Binding var prefersListLayout: Bool - @State private var showLabelsSheet = false @ObservedObject var viewModel: HomeFeedViewModel var body: some View { VStack(spacing: 0) { - SearchBar(searchTerm: $viewModel.searchTerm) - - ZStack(alignment: .bottom) { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - Menu( - content: { - ForEach(LinkedItemFilter.allCases, id: \.self) { filter in - Button(filter.displayName, action: { viewModel.appliedFilter = filter.rawValue }) - } - }, - label: { - TextChipButton.makeMenuButton( - title: LinkedItemFilter(rawValue: viewModel.appliedFilter)?.displayName ?? "Filter" - ) - } - ) - Menu( - content: { - ForEach(LinkedItemSort.allCases, id: \.self) { sort in - Button(sort.displayName, action: { viewModel.appliedSort = sort.rawValue }) - } - }, - label: { - TextChipButton.makeMenuButton( - title: LinkedItemSort(rawValue: viewModel.appliedSort)?.displayName ?? "Sort" - ) - } - ) - TextChipButton.makeAddLabelButton { - showLabelsSheet = true - } - ForEach(viewModel.selectedLabels, id: \.self) { label in - TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: false) { - viewModel.selectedLabels.removeAll { $0.id == label.id } - } - } - ForEach(viewModel.negatedLabels, id: \.self) { label in - TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: true) { - viewModel.negatedLabels.removeAll { $0.id == label.id } - } - } - Spacer() - } - .padding(.horizontal) - .sheet(isPresented: $showLabelsSheet) { - FilterByLabelsView( - initiallySelected: viewModel.selectedLabels, - initiallyNegated: viewModel.negatedLabels - ) { - self.viewModel.selectedLabels = $0 - self.viewModel.negatedLabels = $1 - } - } - } - if viewModel.showLoadingBar { - ShimmeringLoader() - } - } if prefersListLayout || !enableGrid { HomeFeedListView(prefersListLayout: $prefersListLayout, viewModel: viewModel) } else { HomeFeedGridView(viewModel: viewModel) } + }.sheet(isPresented: $viewModel.showLabelsSheet) { + FilterByLabelsView( + initiallySelected: viewModel.selectedLabels, + initiallyNegated: viewModel.negatedLabels + ) { + self.viewModel.selectedLabels = $0 + self.viewModel.negatedLabels = $1 + } } } } @@ -243,6 +214,59 @@ import Views @ObservedObject var viewModel: HomeFeedViewModel + var filtersHeader: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + if viewModel.searchTerm.count > 0 { + TextChipButton.makeSearchFilterButton(title: viewModel.searchTerm) { + viewModel.searchTerm = "" + } + } else { + Menu( + content: { + ForEach(LinkedItemFilter.allCases, id: \.self) { filter in + Button(filter.displayName, action: { viewModel.appliedFilter = filter.rawValue }) + } + }, + label: { + TextChipButton.makeMenuButton( + title: LinkedItemFilter(rawValue: viewModel.appliedFilter)?.displayName ?? "Filter" + ) + } + ) + } + Menu( + content: { + ForEach(LinkedItemSort.allCases, id: \.self) { sort in + Button(sort.displayName, action: { viewModel.appliedSort = sort.rawValue }) + } + }, + label: { + TextChipButton.makeMenuButton( + title: LinkedItemSort(rawValue: viewModel.appliedSort)?.displayName ?? "Sort" + ) + } + ) + TextChipButton.makeAddLabelButton { + viewModel.showLabelsSheet = true + } + ForEach(viewModel.selectedLabels, id: \.self) { label in + TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: false) { + viewModel.selectedLabels.removeAll { $0.id == label.id } + } + } + ForEach(viewModel.negatedLabels, id: \.self) { label in + TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: true) { + viewModel.negatedLabels.removeAll { $0.id == label.id } + } + } + Spacer() + } + .padding(0) + } + .listRowSeparator(.hidden) + } + var body: some View { ZStack { NavigationLink( @@ -251,8 +275,17 @@ import Views ) { EmptyView() } - List { - Section { + VStack(spacing: 0) { + if viewModel.showLoadingBar { + ShimmeringLoader() + } else { + Spacer(minLength: 2) + } + + List { + if viewModel.items.count > 0 || viewModel.searchTerm.count > 0 { + filtersHeader + } ForEach(viewModel.items) { item in FeedCardNavigationLink( item: item, @@ -269,7 +302,7 @@ import Views ) Button( action: { viewModel.itemUnderLabelEdit = item }, - label: { Label("Edit Labels", systemImage: "tag") } + label: { Label(item.labels?.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") } ) Button(action: { withAnimation(.linear(duration: 0.4)) { @@ -358,8 +391,9 @@ import Views } } } + .padding(.top, 0) + .listStyle(PlainListStyle()) } - .listStyle(PlainListStyle()) } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index 65d4f4d75..7e5d23189 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -19,6 +19,7 @@ import Views @Published var itemUnderTitleEdit: LinkedItem? @Published var itemForHighlightsView: LinkedItem? @Published var searchTerm = "" + @Published var scopeSelection = 0 @Published var selectedLabels = [LinkedItemLabel]() @Published var negatedLabels = [LinkedItemLabel]() @Published var snoozePresented = false @@ -30,6 +31,8 @@ import Views @Published var selectedItem: LinkedItem? @Published var linkIsActive = false + @Published var showLabelsSheet = false + @AppStorage(UserDefaultKey.lastSelectedLinkedItemFilter.rawValue) var appliedFilter = LinkedItemFilter.inbox.rawValue @AppStorage(UserDefaultKey.lastItemSyncTime.rawValue) var lastItemSyncTime = DateFormatter.formatterISO8601.string( diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift index b025250da..d7f6781a9 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift @@ -3,14 +3,18 @@ import SwiftUI struct HomeView: View { @StateObject private var viewModel = HomeFeedViewModel() + var navView: some View { + NavigationView { + HomeFeedContainerView(viewModel: viewModel) + } + .navigationViewStyle(.stack) + .accentColor(.appGrayTextContrast) + } + var body: some View { #if os(iOS) if UIDevice.isIPhone { - NavigationView { - HomeFeedContainerView(viewModel: viewModel) - } - .navigationViewStyle(.stack) - .accentColor(.appGrayTextContrast) + navView } else { HomeFeedContainerView(viewModel: viewModel) } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/LibrarySearchView.swift b/apple/OmnivoreKit/Sources/App/Views/Home/LibrarySearchView.swift new file mode 100644 index 000000000..6369f9ac4 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Home/LibrarySearchView.swift @@ -0,0 +1,153 @@ +import Introspect +import Models +import Services +import SwiftUI +import UIKit +import Views + +struct LibrarySearchView: View { + @State private var searchBar: UISearchBar? + @State private var recents: [String] = [] + @StateObject var viewModel = LibrarySearchViewModel() + + @EnvironmentObject var dataService: DataService + @Environment(\.isSearching) var isSearching + @Environment(\.dismiss) private var dismiss + + let homeFeedViewModel: HomeFeedViewModel + + init(homeFeedViewModel: HomeFeedViewModel) { + self.homeFeedViewModel = homeFeedViewModel + } + + func performTypeahead(_ searchTerm: String) { + Task { + await viewModel.search(dataService: self.dataService, searchTerm: searchTerm) + } + } + + func setSearchTerm(_ searchTerm: String) { + viewModel.searchTerm = searchTerm + searchBar?.becomeFirstResponder() + performTypeahead(searchTerm) + } + + func performSearch(_ searchTerm: String) { + let term = searchTerm.trimmingCharacters(in: Foundation.CharacterSet.whitespacesAndNewlines) + viewModel.saveRecentSearch(dataService: dataService, searchTerm: term) + recents = viewModel.recentSearches(dataService: dataService) + homeFeedViewModel.searchTerm = term + + dismiss() + } + + func recentSearchRow(_ term: String) -> some View { + HStack { + HStack { + Image(systemName: "clock.arrow.circlepath") + Text(term).foregroundColor(.appGrayText) + }.onTapGesture { + performSearch(term) + } + + Spacer() + + Image(systemName: "arrow.up.backward") + .onTapGesture { + setSearchTerm(viewModel.searchTerm + (viewModel.searchTerm.count > 0 ? " " : "") + term) + } + .searchCompletion(term) + }.swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button { + withAnimation(.linear(duration: 0.4)) { + viewModel.removeRecentSearch(dataService: dataService, searchTerm: term) + self.recents = viewModel.recentSearches(dataService: dataService) + } + } label: { + Label("Remove", systemImage: "trash") + }.tint(.red) + } + } + + var body: some View { + NavigationView { + innerBody + }.introspectViewController { controller in + searchBar = Introspect.findChild(ofType: UISearchBar.self, in: controller.view) + } + } + + var innerBody: some View { + ZStack { + if let linkRequest = viewModel.linkRequest { + NavigationLink( + destination: WebReaderLoadingContainer(requestID: linkRequest.serverID), + tag: linkRequest, + selection: $viewModel.linkRequest + ) { + EmptyView() + } + } + listBody + .navigationTitle("Search") + .navigationBarItems(trailing: Button(action: { dismiss() }, label: { Text("Close") })) + .navigationBarTitleDisplayMode(NavigationBarItem.TitleDisplayMode.inline) + .searchable(text: $viewModel.searchTerm, placement: .navigationBarDrawer(displayMode: .always)) { + ForEach(viewModel.items) { item in + HStack { + Text(item.title) + Spacer() + Image(systemName: "chevron.right") + }.onTapGesture { + viewModel.linkRequest = LinkRequest(id: UUID(), serverID: item.id) + } + } + } + .onAppear { + self.recents = viewModel.recentSearches(dataService: dataService) + } + .onSubmit(of: .search) { + performSearch(viewModel.searchTerm) + } + .onChange(of: viewModel.searchTerm) { term in + performTypeahead(term) + } + } + } + + var listBody: some View { + VStack { + List { + if viewModel.searchTerm.count == 0 { + if recents.count > 0 { + Section("Recent Searches") { + ForEach(recents, id: \.self) { term in + recentSearchRow(term) + } + } + } + + Section("Narrow with advanced search") { + (Text("**in:** ") + Text("filter to inbox, archive, or all")) + .foregroundColor(.appGrayText) + .onTapGesture { setSearchTerm("is:") } + + (Text("**title:** ") + Text("search for a specific title")) + .foregroundColor(.appGrayText) + .onTapGesture { setSearchTerm("site:") } + + (Text("**has:highlights** ") + Text("any saved read with highlights")) + .foregroundColor(.appGrayText) + .onTapGesture { setSearchTerm("has:highlights") } + + Button(action: {}, label: { + Text("[More on Advanced Search](https://omnivore.app/help/search)") + .underline() + .padding(.top, 25) + }) + } + } + }.listStyle(PlainListStyle()) + } + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/LibrarySearchViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/LibrarySearchViewModel.swift new file mode 100644 index 000000000..f092b1979 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Home/LibrarySearchViewModel.swift @@ -0,0 +1,91 @@ +// +// LibrarySearchViewModel.swift +// +// +// Created by Jackson Harper on 10/10/22. +// + +import CoreData +import Models +import Services +import SwiftUI +import UserNotifications +import Utils +import Views + +@MainActor final class LibrarySearchViewModel: NSObject, ObservableObject { + @Published var items = [TypeaheadSearchItem]() + @Published var isLoading = false + @Published var cursor: String? + @Published var searchTerm = "" + @Published var linkRequest: LinkRequest? + + // These are used to make sure we handle search result + // responses in the right order + var searchIdx = 0 + var receivedIdx = 0 + + @AppStorage(UserDefaultKey.recentSearchTerms.rawValue) var recentSearchTerms: String = "" + + func recentSearches(dataService: DataService) -> [String] { + var results: [String] = [] + dataService.viewContext.performAndWait { + let request = RecentSearchItem.fetchRequest() + let sort = NSSortDescriptor(key: #keyPath(RecentSearchItem.savedAt), ascending: false) + request.sortDescriptors = [sort] + request.fetchLimit = 20 + + results = (try? dataService.viewContext.fetch(request))?.map { $0.term ?? "" } ?? [] + } + return results + } + + func saveRecentSearch(dataService: DataService, searchTerm: String) { + let fetchRequest: NSFetchRequest = RecentSearchItem.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "term == %@", searchTerm) + + let item = ((try? dataService.viewContext.fetch(fetchRequest))?.first) ?? RecentSearchItem(context: dataService.viewContext) + item.term = searchTerm + item.savedAt = Date() + + try? dataService.viewContext.save() + } + + func removeRecentSearch(dataService: DataService, searchTerm: String) { + let fetchRequest: NSFetchRequest = RecentSearchItem.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "term == %@", searchTerm) + + let objects = try? dataService.viewContext.fetch(fetchRequest) + for object in objects ?? [] { + dataService.viewContext.delete(object) + } + + try? dataService.viewContext.save() + } + + func search(dataService: DataService, searchTerm: String, isRefresh _: Bool = false) async { + isLoading = true + let thisSearchIdx = searchIdx + searchIdx += 1 + + let queryResult = try? await dataService.typeaheadSearch(searchTerm: searchTerm) + + // Search results aren't guaranteed to return in order so this + // will discard old results that are returned while a user is typing. + // For example if a user types 'Canucks', often the search results + // for 'C' are returned after 'Canucks' because it takes the backend + // much longer to compute. + if thisSearchIdx > 0, thisSearchIdx <= receivedIdx { + return + } + + if let queryResult = queryResult { + items = queryResult + + isLoading = false + receivedIdx = thisSearchIdx + } + + isLoading = false + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index b2d883adb..f40ca22d5 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -132,9 +132,7 @@ struct WebReaderContainerView: View { .scaleEffect(navBarVisibilityRatio) Spacer() #endif - if FeatureFlag.enableTextToSpeechButton { - audioNavbarItem - } + audioNavbarItem Button( action: { showPreferencesPopover.toggle() }, label: { @@ -177,6 +175,12 @@ struct WebReaderContainerView: View { ) } ) + Button( + action: { + dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0, anchorIndex: 0) + }, + label: { Label("Reset Read Location", systemImage: "arrow.counterclockwise.circle") } + ) Button( action: { shareActionID = UUID() }, label: { Label("Share Original", systemImage: "square.and.arrow.up") } diff --git a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents index 03a45ee30..8dec2a378 100644 --- a/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/apple/OmnivoreKit/Sources/Models/CoreData/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -81,6 +81,11 @@ + + + + + diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/RecentSearchItem.swift b/apple/OmnivoreKit/Sources/Models/DataModels/RecentSearchItem.swift new file mode 100644 index 000000000..47b7b473d --- /dev/null +++ b/apple/OmnivoreKit/Sources/Models/DataModels/RecentSearchItem.swift @@ -0,0 +1,10 @@ +// +// RecentSearch.swift +// +// +// Created by Jackson Harper on 10/10/22. +// + +import Foundation + +extension RecentSearchItem {} diff --git a/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift b/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift index 62669a63c..abb5f387b 100644 --- a/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift +++ b/apple/OmnivoreKit/Sources/Models/LinkedItemFilter.swift @@ -6,6 +6,7 @@ public enum LinkedItemFilter: String, CaseIterable { case newsletters case all case archived + case hasHighlights case files } @@ -22,6 +23,8 @@ public extension LinkedItemFilter { return "All" case .archived: return "Archived" + case .hasHighlights: + return "Highlighted" case .files: return "Files" } @@ -39,6 +42,8 @@ public extension LinkedItemFilter { return "in:all" case .archived: return "in:archive" + case .hasHighlights: + return "has:highlights" case .files: return "type:file" } @@ -84,6 +89,13 @@ public extension LinkedItemFilter { format: "%K == %@", #keyPath(LinkedItem.contentReader), "PDF" ) return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, isPDFPredicate]) + case .hasHighlights: + let hasHighlightsPredicate = NSPredicate( + format: "highlights.@count > 0" + ) + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + hasHighlightsPredicate, notInArchivePredicate + ]) } } } diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift index 345fce5d6..aea393b20 100644 --- a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift @@ -66,7 +66,10 @@ class SpeechPlayerItem: AVPlayerItem { } } - NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: self, queue: OperationQueue.main) { [weak self] _ in + NotificationCenter.default.addObserver( + forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, + object: self, queue: OperationQueue.main + ) { [weak self] _ in guard let self = self else { return } self.completed() } @@ -209,8 +212,7 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate @Published public var durationString: String? @Published public var voiceList: [(name: String, key: String, category: VoiceCategory, selected: Bool)]? - let appEnvironment: AppEnvironment - let networker: Networker + let dataService: DataService var timer: Timer? var player: AVQueuePlayer? @@ -218,10 +220,10 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate var document: SpeechDocument? var synthesizer: SpeechSynthesizer? var durations: [Double]? + var lastReadUpdate = 0.0 - public init(appEnvironment: AppEnvironment, networker: Networker) { - self.appEnvironment = appEnvironment - self.networker = networker + public init(dataService: DataService) { + self.dataService = dataService super.init() self.voiceList = generateVoiceList() @@ -262,6 +264,7 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate player = nil observer = nil synthesizer = nil + lastReadUpdate = 0 itemAudioProperties = nil state = .stopped @@ -292,7 +295,7 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate for itemID in itemIDs { if let document = try? await downloadSpeechFile(itemID: itemID, priority: .low) { - let synthesizer = SpeechSynthesizer(appEnvironment: appEnvironment, networker: networker, document: document) + let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document) do { try await synthesizer.preload() return true @@ -306,7 +309,7 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate public func downloadForOffline(itemID: String) async -> Bool { if let document = try? await downloadSpeechFile(itemID: itemID, priority: .low) { - let synthesizer = SpeechSynthesizer(appEnvironment: appEnvironment, networker: networker, document: document) + let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document) for item in synthesizer.createPlayerItems(from: 0) { do { _ = try await SpeechSynthesizer.download(speechItem: item, redownloadCached: true) @@ -550,11 +553,11 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate if let itemID = itemAudioProperties?.itemID { Task { - let document = try? await downloadSpeechFile(itemID: itemID, priority: .high) + let document = try? await self.downloadSpeechFile(itemID: itemID, priority: .high) DispatchQueue.main.async { if let document = document { - let synthesizer = SpeechSynthesizer(appEnvironment: self.appEnvironment, networker: self.networker, document: document) + let synthesizer = SpeechSynthesizer(appEnvironment: self.dataService.appEnvironment, networker: self.dataService.networker, document: document) self.setTextItems() self.durations = synthesizer.estimatedDurations(forSpeed: self.playbackRate) @@ -687,7 +690,7 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate } } - let synthesizer = SpeechSynthesizer(appEnvironment: appEnvironment, networker: networker, document: document) + let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document) durations = synthesizer.estimatedDurations(forSpeed: playbackRate) self.synthesizer = synthesizer @@ -754,6 +757,7 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate func startTimer() { if timer == nil { + lastReadUpdate = 0 timer = Timer.scheduledTimer(timeInterval: 0.2, target: self, selector: #selector(fireTimer), userInfo: nil, repeats: true) timer?.fire() } @@ -800,6 +804,17 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate } } } + + if timeElapsed - 10 > lastReadUpdate { + let percentProgress = timeElapsed / duration + let anchorIndex = Int((player?.currentItem as? SpeechPlayerItem)?.speechItem.htmlIdx ?? "") ?? 0 + + if let itemID = itemAudioProperties?.itemID { + dataService.updateLinkReadingProgress(itemID: itemID, readingProgress: percentProgress, anchorIndex: anchorIndex) + } + + lastReadUpdate = timeElapsed + } } func clearNowPlayingInfo() { @@ -908,13 +923,13 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate } let path = "/api/article/\(itemID)/speech?voice=\(currentVoice)&secondaryVoice=\(secondaryVoice)&priority=\(priority)\(isoLangForCurrentVoice())" - guard let url = URL(string: path, relativeTo: appEnvironment.serverBaseURL) else { + guard let url = URL(string: path, relativeTo: dataService.appEnvironment.serverBaseURL) else { throw BasicError.message(messageText: "Invalid audio URL") } var request = URLRequest(url: url) request.httpMethod = "GET" - for (header, value) in networker.defaultHeaders { + for (header, value) in dataService.networker.defaultHeaders { request.setValue(value, forHTTPHeaderField: header) } diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResource.swift b/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResource.swift index 4bf7f11df..0c4698cbb 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResource.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Networking/ServerResource.swift @@ -65,7 +65,8 @@ extension URLRequest { var headers = [ "content-type": "application/json", "user-agent": userAgent, - "app-language": Locale.preferredLanguages[0] + "app-language": Locale.preferredLanguages[0], + "X-OmnivoreClient": "ios" ] if let deviceLanguage = NSLocale.current.languageCode { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/OfflineSync.swift b/apple/OmnivoreKit/Sources/Services/DataService/OfflineSync.swift index be0294a82..60e85ac67 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/OfflineSync.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/OfflineSync.swift @@ -53,7 +53,6 @@ public extension DataService { try await updateLinkedItemStatus(id: id, newId: nil, status: .isSyncing) let uploadRequest = try await uploadFileRequest(id: id, url: url) - print("UPLOAD REQUEST, ORIGINAL ID, NEW ID", id, uploadRequest.pageId) if let urlString = uploadRequest.urlString, let uploadUrl = URL(string: urlString) { try await uploadFile(id: uploadRequest.pageId, localPdfURL: localPdfURL, url: uploadUrl) } else { diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/TypeAheadSearchQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/TypeAheadSearchQuery.swift new file mode 100644 index 000000000..c3aa87ba9 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/TypeAheadSearchQuery.swift @@ -0,0 +1,65 @@ +import CoreData +import Foundation +import Models +import SwiftGraphQL +import Utils + +public struct TypeaheadSearchItem: Identifiable { + public let id: String + public let title: String + let slug: String + let siteName: String? +} + +public extension DataService { + // swiftlint:disable:next function_body_length + func typeaheadSearch(searchTerm: String) async throws -> [TypeaheadSearchItem] { + enum QueryResult { + case success(result: [TypeaheadSearchItem]) + case error(error: String) + } + + let typeaheadSelection = Selection.TypeaheadSearchItem { + TypeaheadSearchItem( + id: try $0.id(), + title: try $0.title(), + slug: try $0.slug(), + siteName: try $0.siteName() + ) + } + + let selection = Selection { + try $0.on( + typeaheadSearchError: .init { + QueryResult.error(error: try $0.errorCodes().description) + }, + typeaheadSearchSuccess: .init { + QueryResult.success(result: try $0.items(selection: typeaheadSelection.list)) + } + ) + } + + let query = Selection.Query { + try $0.typeaheadSearch(query: searchTerm, selection: selection) + } + + let path = appEnvironment.graphqlPath + let headers = networker.defaultHeaders + + return try await withCheckedThrowingContinuation { continuation in + send(query, to: path, headers: headers) { queryResult in + guard let payload = try? queryResult.get() else { + continuation.resume(throwing: ContentFetchError.network) + return + } + + switch payload.data { + case let .success(result: result): + continuation.resume(returning: result) + case .error: + continuation.resume(throwing: ContentFetchError.badData) + } + } + } + } +} diff --git a/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift b/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift index 5c8ac8803..622ded52e 100644 --- a/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift +++ b/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift @@ -14,6 +14,5 @@ public enum FeatureFlag { public static let enableShareButton = false public static let enableSnooze = false public static let enableGridCardsOnPhone = false - public static let enableTextToSpeechButton = true public static let enableHighlightsView = true } diff --git a/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift b/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift index caef28d45..5abfe6c5f 100644 --- a/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift +++ b/apple/OmnivoreKit/Sources/Utils/UserDefaultKeys.swift @@ -17,4 +17,5 @@ public enum UserDefaultKey: String { case textToSpeechPreferredVoice case textToSpeechDefaultLanguage case textToSpeechPreloadEnabled + case recentSearchTerms } diff --git a/apple/OmnivoreKit/Sources/Views/TextChip.swift b/apple/OmnivoreKit/Sources/Views/TextChip.swift index 01320f242..845a0810f 100644 --- a/apple/OmnivoreKit/Sources/Views/TextChip.swift +++ b/apple/OmnivoreKit/Sources/Views/TextChip.swift @@ -75,6 +75,10 @@ public struct TextChipButton: View { TextChipButton(title: title, color: .systemGray6, actionType: .show, negated: false, onTap: {}) } + public static func makeSearchFilterButton(title: String, onTap: @escaping () -> Void) -> TextChipButton { + TextChipButton(title: "Search: \(title)", color: .appCtaYellow, actionType: .clear, negated: false, onTap: onTap) + } + public static func makeShowOptionsButton(title: String, onTap: @escaping () -> Void) -> TextChipButton { TextChipButton(title: title, color: .appButtonBackground, actionType: .add, negated: false, onTap: onTap) } @@ -97,10 +101,11 @@ public struct TextChipButton: View { case remove case add case show + case clear var systemIconName: String { switch self { - case .remove: + case .clear, .remove: return "xmark" case .add: return "plus" @@ -144,9 +149,10 @@ public struct TextChipButton: View { .font(.appFootnote) .foregroundColor(foregroundColor) .lineLimit(1) - .background(Capsule().fill(color)) + .background(Rectangle().fill(color)) + .cornerRadius(8) } - .padding(.vertical, 12) + .padding(.vertical, 0) .contentShape(Rectangle()) .onTapGesture { onTap() } } diff --git a/packages/web/components/templates/article/Article.tsx b/packages/web/components/templates/article/Article.tsx index 4dd39a5fe..a9e17f885 100644 --- a/packages/web/components/templates/article/Article.tsx +++ b/packages/web/components/templates/article/Article.tsx @@ -48,7 +48,6 @@ export function Article(props: ArticleProps): JSX.Element { const debouncedSetReadingProgress = useMemo( () => debounce((readingProgress: number) => { - console.log('setReadingProgress', readingProgress) setReadingProgress(readingProgress) }, 2000), []