Merge pull request #1297 from omnivore-app/feat/navbar-refactor

Refactor how we do search and navbar on the library
This commit is contained in:
Satindar Dhillon
2022-10-12 10:05:53 -07:00
committed by GitHub
19 changed files with 727 additions and 312 deletions

View File

@ -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)
}
}

View File

@ -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)
})
}
}
}

View File

@ -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())
}
}
}

View File

@ -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(

View File

@ -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)
}

View File

@ -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())
}
}
}

View File

@ -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<Models.RecentSearchItem> = 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<Models.RecentSearchItem> = 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
}
}

View File

@ -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") }

View File

@ -81,6 +81,11 @@
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="RecentSearchItem" representedClassName="RecentSearchItem" syncable="YES" codeGenerationType="class">
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="savedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="term" optional="YES" attributeType="String"/>
</entity>
<entity name="Viewer" representedClassName="Viewer" syncable="YES" codeGenerationType="class">
<attribute name="name" attributeType="String"/>
<attribute name="profileImageURL" optional="YES" attributeType="String"/>

View File

@ -0,0 +1,10 @@
//
// RecentSearch.swift
//
//
// Created by Jackson Harper on 10/10/22.
//
import Foundation
extension RecentSearchItem {}

View File

@ -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
])
}
}
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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<QueryResult, Unions.TypeaheadSearchResult> {
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)
}
}
}
}
}

View File

@ -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
}

View File

@ -17,4 +17,5 @@ public enum UserDefaultKey: String {
case textToSpeechPreferredVoice
case textToSpeechDefaultLanguage
case textToSpeechPreloadEnabled
case recentSearchTerms
}

View File

@ -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() }
}

View File

@ -48,7 +48,6 @@ export function Article(props: ArticleProps): JSX.Element {
const debouncedSetReadingProgress = useMemo(
() =>
debounce((readingProgress: number) => {
console.log('setReadingProgress', readingProgress)
setReadingProgress(readingProgress)
}, 2000),
[]