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:
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
153
apple/OmnivoreKit/Sources/App/Views/Home/LibrarySearchView.swift
Normal file
153
apple/OmnivoreKit/Sources/App/Views/Home/LibrarySearchView.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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") }
|
||||
|
||||
@ -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"/>
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
//
|
||||
// RecentSearch.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 10/10/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension RecentSearchItem {}
|
||||
@ -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
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -17,4 +17,5 @@ public enum UserDefaultKey: String {
|
||||
case textToSpeechPreferredVoice
|
||||
case textToSpeechDefaultLanguage
|
||||
case textToSpeechPreloadEnabled
|
||||
case recentSearchTerms
|
||||
}
|
||||
|
||||
@ -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() }
|
||||
}
|
||||
|
||||
@ -48,7 +48,6 @@ export function Article(props: ArticleProps): JSX.Element {
|
||||
const debouncedSetReadingProgress = useMemo(
|
||||
() =>
|
||||
debounce((readingProgress: number) => {
|
||||
console.log('setReadingProgress', readingProgress)
|
||||
setReadingProgress(readingProgress)
|
||||
}, 2000),
|
||||
[]
|
||||
|
||||
Reference in New Issue
Block a user