Files
omnivore/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift
2024-01-04 12:40:47 +08:00

1158 lines
40 KiB
Swift

import CoreData
import Models
import Services
import SwiftUI
import Transmission
import UserNotifications
import Utils
import Views
struct FiltersHeader: View {
@ObservedObject var viewModel: HomeFeedViewModel
var body: some View {
GeometryReader { reader in
ScrollView(.horizontal, showsIndicators: false) {
HStack {
if viewModel.searchTerm.count > 0 {
TextChipButton.makeSearchFilterButton(title: viewModel.searchTerm) {
viewModel.searchTerm = ""
}.frame(maxWidth: reader.size.width * 0.66)
} else {
// if UIDevice.isIPhone {
Menu(
content: {
ForEach(viewModel.filters.filter { $0.folder == viewModel.currentFolder }) { filter in
Button(filter.name, action: {
viewModel.appliedFilter = filter
})
}
},
label: {
TextChipButton.makeMenuButton(
title: viewModel.appliedFilter?.name ?? "-",
color: .systemGray6
)
}
).buttonStyle(.plain)
// }
}
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",
color: .systemGray6
)
}
).buttonStyle(.plain)
TextChipButton.makeAddLabelButton(color: .systemGray6, onTap: { 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(.top, 0)
.padding(.bottom, 10)
.padding(.leading, 15)
.listRowSpacing(0)
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
.frame(maxWidth: .infinity, minHeight: 38)
.background(Color.systemBackground)
.dynamicTypeSize(.small ... .accessibility1)
}
}
struct EmptyState: View {
@ObservedObject var viewModel: HomeFeedViewModel
@EnvironmentObject var dataService: DataService
@State var showSendNewslettersAlert = false
var followingEmptyState: some View {
VStack(alignment: .center, spacing: 20) {
if viewModel.stopUsingFollowingPrimer {
VStack(spacing: 10) {
Image.relaxedSlothLight
Text("You are all caught up.").foregroundColor(Color.extensionTextSubtle)
Button(action: {
Task {
await viewModel.loadItems(dataService: dataService, isRefresh: true, loadingBarStyle: .simple)
}
}, label: { Text("Refresh").bold() })
.foregroundColor(Color.blue)
}
} else {
Text("You don't have any Feed items.")
.font(Font.system(size: 18, weight: .bold))
Text("Add an RSS/Atom feed")
.foregroundColor(Color.blue)
.onTapGesture {
viewModel.showAddFeedView = true
}
Text("Send your newsletters to following")
.foregroundColor(Color.blue)
.onTapGesture {
showSendNewslettersAlert = true
}
Text("Hide the Following tab")
.foregroundColor(Color.blue)
.onTapGesture {
viewModel.showHideFollowingAlert = true
}
}
}
.frame(minHeight: 400)
.frame(maxWidth: .infinity)
.padding()
.alert("Update newsletter destination", isPresented: $showSendNewslettersAlert, actions: {
Button(action: {
Task {
await viewModel.modifyingNewsletterDestinationToFollowing(dataService: dataService)
}
}, label: { Text("OK") })
Button(LocalText.cancelGeneric, role: .cancel) { showSendNewslettersAlert = false }
}, message: {
// swiftlint:disable:next line_length
Text("Your email address destination folders will be modified to send to this tab.\n\nAll new newsletters will appear here. You can modify the destination for each individual email address and subscription in your settings.")
})
}
var body: some View {
if viewModel.isModifyingNewsletterDestination {
return AnyView(
VStack {
Text("Modifying newsletter destinations...")
ProgressView()
}.frame(maxWidth: .infinity, maxHeight: .infinity)
)
} else if viewModel.currentFolder == "following" {
return AnyView(followingEmptyState)
} else {
return AnyView(Group {
Spacer()
VStack(alignment: .center, spacing: 20) {
Text("No results found for this query")
.font(Font.system(size: 18, weight: .bold))
}
.frame(minHeight: 400)
.frame(maxWidth: .infinity)
.padding()
Spacer()
})
}
}
}
struct AnimatingCellHeight: AnimatableModifier {
var height: CGFloat = 0
var animatableData: CGFloat {
get { height }
set { height = newValue }
}
func body(content: Content) -> some View {
content.frame(height: height, alignment: .top).clipped()
}
}
// swiftlint:disable file_length
#if os(iOS)
private let enableGrid = UIDevice.isIPad || FeatureFlag.enableGridCardsOnPhone
@MainActor
struct HomeFeedContainerView: View {
@State var hasHighlightMutations = false
@State var searchPresented = false
@State var showAddLinkView = false
@State var isListScrolled = false
@State var listTitle = ""
@State var isEditMode: EditMode = .inactive
@State var showExpandedAudioPlayer = false
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = true
@ObservedObject var viewModel: HomeFeedViewModel
@State private var selection = Set<String>()
init(viewModel: HomeFeedViewModel) {
_viewModel = ObservedObject(wrappedValue: viewModel)
}
func loadItems(isRefresh: Bool) {
Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) }
}
var showFeatureCards: Bool {
isEditMode == .inactive &&
(viewModel.currentListConfig?.hasFeatureCards ?? false) &&
!viewModel.hideFeatureSection &&
viewModel.fetcher.items.count > 0 &&
viewModel.searchTerm.isEmpty &&
viewModel.selectedLabels.isEmpty &&
viewModel.negatedLabels.isEmpty &&
viewModel.appliedFilter?.name.lowercased() == "inbox"
}
var body: some View {
ZStack {
HomeFeedView(
listTitle: $listTitle,
isListScrolled: $isListScrolled,
prefersListLayout: $prefersListLayout,
isEditMode: $isEditMode,
selection: $selection,
viewModel: viewModel,
showFeatureCards: showFeatureCards
)
.refreshable {
loadItems(isRefresh: true)
}
.onChange(of: viewModel.presentWebContainer) { _ in
if !viewModel.presentWebContainer {
viewModel.linkRequest = nil
}
}
.onChange(of: viewModel.searchTerm) { _ in
// Maybe we should debounce this, but
// it feels like it works ok without
loadItems(isRefresh: true)
}
.onChange(of: viewModel.selectedLabels) { _ in
loadItems(isRefresh: true)
}
.onChange(of: viewModel.negatedLabels) { _ in
loadItems(isRefresh: true)
}
.onChange(of: viewModel.appliedFilter) { _ in
loadItems(isRefresh: true)
}
.onChange(of: viewModel.appliedSort) { _ in
loadItems(isRefresh: true)
}
if UIDevice.isIPad {
VStack(spacing: 0) {
Spacer()
if let audioProperties = audioController.itemAudioProperties {
MiniPlayerViewer(itemAudioProperties: audioProperties)
.padding(.top, 10)
.padding(.bottom, 20)
.background(Color.themeTabBarColor)
.onTapGesture {
showExpandedAudioPlayer = true
}
}
}
}
}
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
ApplyLabelsView(mode: .item(item), onSave: nil)
}
.sheet(item: $viewModel.itemUnderTitleEdit) { item in
LinkedItemMetadataEditView(item: item)
}
.sheet(item: $viewModel.itemForHighlightsView) { item in
NotebookView(viewModel: NotebookViewModel(item: item), hasHighlightMutations: $hasHighlightMutations)
}
.sheet(isPresented: $viewModel.showAddFeedView) {
NavigationView {
LibraryAddFeedView(dismiss: {
viewModel.showAddFeedView = false
}, toastOperationHandler: nil)
}
}
.sheet(isPresented: $showAddLinkView) {
NavigationView {
LibraryAddLinkView()
}
}
.fullScreenCover(isPresented: $showExpandedAudioPlayer) {
ExpandedAudioPlayer(
delete: {
showExpandedAudioPlayer = false
audioController.stop()
viewModel.removeLibraryItem(dataService: dataService, objectID: $0)
},
archive: {
showExpandedAudioPlayer = false
audioController.stop()
viewModel.setLinkArchived(dataService: dataService, objectID: $0, archived: true)
},
viewArticle: { itemID in
if let article = try? dataService.viewContext.existingObject(with: itemID) as? Models.LibraryItem {
viewModel.pushFeedItem(item: article)
}
}
)
}
.toolbar {
toolbarItems
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
Task {
await viewModel.loadNewItems(dataService: dataService)
}
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("PushJSONArticle"))) { notification in
guard let jsonArticle = notification.userInfo?["article"] as? JSONArticle else { return }
guard let objectID = dataService.persist(jsonArticle: jsonArticle) else { return }
guard let linkedItem = dataService.viewContext.object(with: objectID) as? Models.LibraryItem else { return }
viewModel.pushFeedItem(item: linkedItem)
viewModel.selectedItem = linkedItem
viewModel.linkIsActive = true
}
.fullScreenCover(isPresented: $searchPresented) {
LibrarySearchView(homeFeedViewModel: self.viewModel)
}
.task {
await viewModel.loadFilters(dataService: dataService)
if viewModel.appliedFilter == nil {
viewModel.setDefaultFilter()
}
// Once the user has seen at least one following item we stop displaying the
// initial help view
if viewModel.currentFolder == "following", viewModel.fetcher.items.count > 0 {
viewModel.stopUsingFollowingPrimer = true
}
}
.environment(\.editMode, self.$isEditMode)
.navigationBarTitleDisplayMode(.inline)
}
var toolbarItems: some ToolbarContent {
Group {
ToolbarItemGroup(placement: .barLeading) {
if UIDevice.isIPhone || horizontalSizeClass != .compact {
VStack(alignment: .leading) {
let showDate = isListScrolled && !listTitle.isEmpty
if let title = viewModel.appliedFilter?.name {
Text(title)
.font(Font.system(size: showDate ? 10 : 24, weight: .semibold))
if showDate, prefersListLayout, isListScrolled || !showFeatureCards {
Text(listTitle)
.font(Font.system(size: 15, weight: .regular))
.foregroundColor(Color.appGrayText)
}
}
}
.frame(maxWidth: .infinity, alignment: .bottomLeading)
}
}
ToolbarItemGroup(placement: .barTrailing) {
if prefersListLayout {
Button(
action: { isEditMode = isEditMode == .active ? .inactive : .active },
label: {
Image
.selectMultiple
.foregroundColor(Color.toolbarItemForeground)
}
).buttonStyle(.plain)
.padding(.horizontal, UIDevice.isIPad ? 5 : 0)
}
if enableGrid {
Button(
action: { prefersListLayout.toggle() },
label: {
Image(systemName: prefersListLayout ? "square.grid.2x2" : "list.bullet")
.foregroundColor(Color.toolbarItemForeground)
}
).buttonStyle(.plain)
.padding(.horizontal, UIDevice.isIPad ? 5 : 0)
}
Button(
action: {
if viewModel.currentFolder == "inbox" {
showAddLinkView = true
} else if viewModel.currentFolder == "following" {
viewModel.showAddFeedView = true
}
},
label: {
Image.addLink
.foregroundColor(Color.toolbarItemForeground)
}
).buttonStyle(.plain)
.padding(.horizontal, UIDevice.isIPad ? 5 : 0)
Button(
action: {
searchPresented = true
isEditMode = .inactive
},
label: {
Image
.magnifyingGlass
.foregroundColor(Color.toolbarItemForeground)
}
).buttonStyle(.plain)
.padding(.horizontal, UIDevice.isIPad ? 5 : 0)
}
ToolbarItemGroup(placement: .bottomBar) {
if isEditMode == .active {
Button(action: {
viewModel.bulkAction(dataService: dataService, action: .delete, items: Array(selection))
isEditMode = .inactive
}, label: { Image.toolbarTrash })
.padding(.horizontal, UIDevice.isIPad ? 10 : 5)
Button(action: {
viewModel.bulkAction(dataService: dataService, action: .archive, items: Array(selection))
isEditMode = .inactive
}, label: { Image.toolbarArchive })
.padding(.horizontal, UIDevice.isIPad ? 10 : 5)
Spacer()
Text("\(selection.count) selected").font(.footnote)
Spacer()
Button(action: { isEditMode = .inactive }, label: { Text("Cancel") })
}
}
}
}
}
@MainActor
struct HomeFeedView: View {
@EnvironmentObject var dataService: DataService
@Binding var listTitle: String
@Binding var isListScrolled: Bool
@Binding var prefersListLayout: Bool
@Binding var isEditMode: EditMode
@Binding var selection: Set<String>
@ObservedObject var viewModel: HomeFeedViewModel
let showFeatureCards: Bool
var body: some View {
VStack(spacing: 0) {
if let linkRequest = viewModel.linkRequest, viewModel.currentListConfig?.hasReadNowSection ?? false {
PresentationLink(
transition: PresentationLinkTransition.slide(
options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing,
options:
PresentationLinkTransition.Options(
modalPresentationCapturesStatusBarAppearance: true
))),
isPresented: $viewModel.presentWebContainer,
destination: {
WebReaderLoadingContainer(requestID: linkRequest.serverID)
.background(ThemeManager.currentBgColor)
}, label: {
EmptyView()
}
)
}
if prefersListLayout || !enableGrid {
HomeFeedListView(
listTitle: $listTitle,
isListScrolled: $isListScrolled,
prefersListLayout: $prefersListLayout,
isEditMode: $isEditMode,
selection: $selection,
viewModel: viewModel,
showFeatureCards: showFeatureCards
)
} else {
HomeFeedGridView(
viewModel: viewModel,
isListScrolled: $isListScrolled
)
}
}.sheet(isPresented: $viewModel.showLabelsSheet) {
FilterByLabelsView(
initiallySelected: viewModel.selectedLabels,
initiallyNegated: viewModel.negatedLabels
) {
viewModel.selectedLabels = $0
viewModel.negatedLabels = $1
}
}
.popup(isPresented: $viewModel.showSnackbar) {
if let operation = viewModel.snackbarOperation {
Snackbar(isShowing: $viewModel.showSnackbar, operation: operation)
} else {
EmptyView()
}
} customize: {
$0
.type(.toast)
.autohideIn(2)
.position(.bottom)
.animation(.spring())
.isOpaque(false)
}
.onReceive(NSNotification.librarySnackBarPublisher) { notification in
if !viewModel.showSnackbar {
if let message = notification.userInfo?["message"] as? String {
viewModel.snackbarOperation = SnackbarOperation(message: message,
undoAction: notification.userInfo?["undoAction"] as? SnackbarUndoAction)
viewModel.showSnackbar = true
}
}
}
}
}
struct HomeFeedListView: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
@Binding var listTitle: String
@Binding var isListScrolled: Bool
@Binding var prefersListLayout: Bool
@Binding var isEditMode: EditMode
@State private var showHideFeatureAlert = false
@Binding var selection: Set<String>
@ObservedObject var viewModel: HomeFeedViewModel
let showFeatureCards: Bool
@State var shouldScrollToTop = false
@State var topItem: Models.LibraryItem?
@ObservedObject var networkMonitor = NetworkMonitor()
var filtersHeader: some View {
FiltersHeader(viewModel: viewModel)
.overlay(Rectangle()
.padding(.leading, 15)
.frame(width: nil, height: 0.5, alignment: .bottom)
.foregroundColor(isListScrolled && UIDevice.isIPhone ? Color(hex: "#3D3D3D") : Color.systemBackground), alignment: .bottom)
.dynamicTypeSize(.small ... .accessibility1)
}
func menuItems(for item: Models.LibraryItem) -> some View {
libraryItemMenu(dataService: dataService, viewModel: viewModel, item: item)
}
var featureCard: some View {
VStack(spacing: 0) {
if Color.isDarkMode {
Color(hex: "#3D3D3D").frame(maxWidth: .infinity, maxHeight: 0.5)
}
VStack(alignment: .leading, spacing: 15) {
HStack {
Menu(content: {
Button(action: {
viewModel.fetcher.updateFeatureFilter(context: dataService.viewContext, filter: .continueReading)
}, label: {
Text("Continue Reading")
})
Button(action: {
viewModel.fetcher.updateFeatureFilter(context: dataService.viewContext, filter: .pinned)
}, label: {
Text("Pinned")
})
Button(action: {
viewModel.fetcher.updateFeatureFilter(context: dataService.viewContext, filter: .newsletters)
}, label: {
Text("Newsletters")
})
Button(action: {
showHideFeatureAlert = true
}, label: {
Text("Hide this Section")
})
}, label: {
Group {
HStack(alignment: .center) {
Image(systemName: "line.3.horizontal.decrease")
.font(Font.system(size: 13, weight: .regular))
Text((FeaturedItemFilter(rawValue: viewModel.fetcher.featureFilter) ?? .continueReading).title)
.font(Font.system(size: 13, weight: .medium))
}
.tint(Color(hex: "#007AFF"))
.padding(.vertical, 5)
.padding(.horizontal, 7)
.background(Color(hex: "#007AFF")?.opacity(0.1))
.cornerRadius(5)
}.frame(maxWidth: .infinity, alignment: .leading)
}).buttonStyle(.plain)
Spacer()
}
.padding(.top, 10)
.padding(.horizontal, 15)
GeometryReader { geo in
ScrollView(.horizontal, showsIndicators: false) {
if viewModel.fetcher.featureItems.count > 0 {
HStack(alignment: .top, spacing: 15) {
Spacer(minLength: 1).frame(width: 1)
ForEach(viewModel.fetcher.featureItems) { item in
LibraryFeatureCardNavigationLink(item: item, viewModel: viewModel)
}
Spacer(minLength: 1).frame(width: 1)
}
.padding(.top, 0)
} else {
Text((FeaturedItemFilter(rawValue: viewModel.fetcher.featureFilter) ?? .continueReading).emptyMessage)
.padding(.horizontal, UIDevice.isIPad ? 20 : 10)
.font(Font.system(size: 14, weight: .regular))
.foregroundColor(Color(hex: "#898989"))
.frame(maxWidth: geo.size.width)
.frame(height: 60, alignment: .topLeading)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
.background(Color.themeFeatureBackground)
.frame(height: 190)
if !Color.isDarkMode {
VStack {
LinearGradient(gradient: Gradient(colors: [.black.opacity(0.06), .systemGray6]),
startPoint: .top, endPoint: .bottom)
.frame(maxWidth: .infinity, maxHeight: 3)
.opacity(0.4)
Spacer()
}
.background(Color.systemGray6)
.frame(maxWidth: .infinity)
} else {
VStack {
Color(hex: "#3D3D3D").frame(maxWidth: .infinity, maxHeight: 0.5)
Spacer()
}
.background(Color.systemBackground)
.frame(maxWidth: .infinity)
}
}
}
struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero
static func reduce(value _: inout CGPoint, nextValue _: () -> CGPoint) {}
}
func setTopItem(_ item: Models.LibraryItem) {
if let date = item.savedAt, let daysAgo = Calendar.current.dateComponents([.day], from: date, to: Date()).day {
if daysAgo < 1 {
let formatter = DateFormatter()
formatter.timeStyle = .none
formatter.dateStyle = .long
formatter.doesRelativeDateFormatting = true
if let str = formatter.string(for: date) {
listTitle = str.capitalized
}
} else if daysAgo < 2 {
let formatter = RelativeDateTimeFormatter()
formatter.dateTimeStyle = .named
if let str = formatter.string(for: date) {
listTitle = str.capitalized
}
} else if daysAgo < 5 {
let formatter = DateFormatter()
formatter.dateFormat = "EEEE"
if let str = formatter.string(for: date) {
listTitle = str
}
} else {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
if let str = formatter.string(for: date) {
listTitle = str
}
}
topItem = item
}
}
var redactedItems: some View {
ForEach(Array(fakeLibraryItems(dataService: dataService).enumerated()), id: \.1.id) { _, item in
let horizontalInset = CGFloat(UIDevice.isIPad ? 20 : 10)
LibraryItemCard(item: item, viewer: dataService.currentViewer)
.listRowSeparatorTint(Color.thBorderColor)
.listRowInsets(.init(top: 0, leading: horizontalInset, bottom: 10, trailing: horizontalInset))
}.redacted(reason: .placeholder)
}
var listItems: some View {
ForEach(Array(viewModel.fetcher.items.enumerated()), id: \.1.unwrappedID) { idx, item in
let horizontalInset = CGFloat(UIDevice.isIPad ? 20 : 10)
LibraryItemListNavigationLink(
item: item,
viewModel: viewModel
)
.background(GeometryReader { geometry in
Color.clear
.preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("scroll")).origin)
})
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
if value.y < 100, value.y > 0 {
if item.savedAt != nil, topItem != item {
setTopItem(item)
}
}
}
.listRowSeparatorTint(Color.thBorderColor)
.listRowInsets(.init(top: 0, leading: horizontalInset, bottom: 10, trailing: horizontalInset))
.contextMenu {
menuItems(for: item)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
if let listConfig = viewModel.currentListConfig {
ForEach(listConfig.leadingSwipeActions, id: \.self) { action in
swipeActionButton(action: action, item: item)
}
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
if let listConfig = viewModel.currentListConfig {
ForEach(listConfig.trailingSwipeActions, id: \.self) { action in
swipeActionButton(action: action, item: item)
}
}
}
.onAppear {
if idx >= viewModel.fetcher.items.count - 5 {
Task {
await viewModel.loadMore(dataService: dataService)
}
}
}
}
}
var body: some View {
VStack(spacing: 0) {
Color.systemBackground.frame(height: 1)
ScrollViewReader { reader in
List(selection: $selection) {
Section(content: {
EmptyView().id("TOP")
if let appliedFilter = viewModel.appliedFilter,
networkMonitor.status == .disconnected,
!appliedFilter.allowLocalFetch
{
HStack {
Text("This search requires an internet connection.")
.padding()
.foregroundColor(Color.white)
.frame(maxWidth: .infinity, alignment: .center)
}
.background(Color.blue)
.frame(maxWidth: .infinity, alignment: .center)
.listRowSeparator(.hidden, edges: .all)
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
} else {
if showFeatureCards {
featureCard
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
.listRowSeparator(.hidden, edges: .all)
.modifier(AnimatingCellHeight(height: 190 + 13))
.onDisappear {
withAnimation {
isListScrolled = true
}
}
.onAppear {
withAnimation {
isListScrolled = false
}
}
}
if viewModel.showLoadingBar == .redacted {
redactedItems
} else if viewModel.showLoadingBar == .simple {
VStack {
ProgressView()
}
.frame(minHeight: 400)
.frame(maxWidth: .infinity)
.padding()
.listRowSeparator(.hidden, edges: .all)
} else if viewModel.fetcher.items.isEmpty {
EmptyState(viewModel: viewModel)
.listRowSeparator(.hidden, edges: .all)
} else {
listItems
}
}
}, header: {
filtersHeader
})
BottomView(viewModel: viewModel)
}
.padding(0)
.listStyle(.plain)
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
.coordinateSpace(name: "scroll")
.onChange(of: shouldScrollToTop) { _ in
if shouldScrollToTop {
withAnimation {
reader.scrollTo("TOP", anchor: .top)
}
}
shouldScrollToTop = false
}
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ScrollToTop"))) { _ in
shouldScrollToTop = true
}
}
.alert("The Feature Section will be removed from your library. You can add it back from the filter settings in your profile.",
isPresented: $showHideFeatureAlert) {
Button("OK", role: .destructive) {
viewModel.hideFeatureSection = true
}
Button(LocalText.cancelGeneric, role: .cancel) { self.showHideFeatureAlert = false }
}
.alert("The Following tab will be hidden. You can add it back from the filter settings in your profile.",
isPresented: $viewModel.showHideFollowingAlert) {
Button("OK", role: .destructive) {
viewModel.hideFollowingTab = true
}
Button(LocalText.cancelGeneric, role: .cancel) { viewModel.showHideFollowingAlert = false }
}
.introspectNavigationController { nav in
nav.navigationBar.shadowImage = UIImage()
nav.navigationBar.setBackgroundImage(UIImage(), for: .default)
}
}
func swipeActionButton(action: SwipeAction, item: Models.LibraryItem) -> AnyView {
switch action {
case .pin:
let isPinned = item.labels?.allObjects.first { ($0 as? LinkedItemLabel)?.name == "Pinned" } != nil
return AnyView(Button(action: {
if isPinned {
viewModel.unpinItem(dataService: dataService, item: item)
} else {
viewModel.pinItem(dataService: dataService, item: item)
}
}, label: {
VStack {
Image.pinRotated
Text(isPinned ? "Unpin" : "Pin")
}
}).tint(Color(hex: "#0A84FF")))
case .archive:
return AnyView(Button(action: {
withAnimation(.linear(duration: 0.4)) {
viewModel.setLinkArchived(dataService: dataService, objectID: item.objectID, archived: !item.isArchived)
}
}, label: {
Label(!item.isArchived ? "Archive" : "Unarchive",
systemImage: !item.isArchived ? "archivebox" : "tray.and.arrow.down.fill")
})
.tint(!item.isArchived ? .green : .indigo))
case .delete:
return AnyView(Button(
action: {
viewModel.removeLibraryItem(dataService: dataService, objectID: item.objectID)
},
label: {
Label("Remove", systemImage: "trash")
}
).tint(.red))
case .moveToInbox:
return AnyView(Button(
action: {
viewModel.moveToFolder(dataService: dataService, item: item, folder: "inbox")
},
label: {
Label(title: { Text("Move to Library") },
icon: { Image.tabLibrary })
}
).tint(Color(hex: "#0A84FF")))
}
}
}
struct HomeFeedGridView: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
@State var isContextMenuOpen = false
@ObservedObject var viewModel: HomeFeedViewModel
@Binding var isListScrolled: Bool
func contextMenuActionHandler(item: Models.LibraryItem, action: GridCardAction) {
switch action {
case .viewHighlights:
viewModel.itemForHighlightsView = item
case .toggleArchiveStatus:
viewModel.setLinkArchived(dataService: dataService, objectID: item.objectID, archived: !item.isArchived)
case .delete:
viewModel.removeLibraryItem(dataService: dataService, objectID: item.objectID)
case .editLabels:
viewModel.itemUnderLabelEdit = item
case .editTitle:
viewModel.itemUnderTitleEdit = item
}
}
func loadItems(isRefresh: Bool) {
Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) }
}
var filtersHeader: some View {
FiltersHeader(viewModel: viewModel)
.overlay(Rectangle()
.padding(.leading, 15)
.frame(width: nil, height: 0.5, alignment: .bottom)
.foregroundColor(isListScrolled && UIDevice.isIPhone ? Color(hex: "#3D3D3D") : Color.systemBackground), alignment: .bottom)
.dynamicTypeSize(.small ... .accessibility1)
}
func menuItems(for item: Models.LibraryItem) -> some View {
libraryItemMenu(dataService: dataService, viewModel: viewModel, item: item)
}
var body: some View {
VStack(alignment: .leading) {
Color.systemBackground.frame(height: 1)
filtersHeader
.onAppear {
withAnimation {
isListScrolled = false
}
}
.onDisappear {
withAnimation {
isListScrolled = true
}
}
.padding(.horizontal, 20)
.frame(maxHeight: 35)
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 325, maximum: 400), spacing: 16)], alignment: .center, spacing: 30) {
if viewModel.showLoadingBar == .redacted {
ForEach(fakeLibraryItems(dataService: dataService), id: \.id) { item in
GridCard(item: item)
.aspectRatio(1.0, contentMode: .fill)
.background(Color.systemBackground)
.cornerRadius(6)
}.redacted(reason: .placeholder)
} else if viewModel.showLoadingBar == .simple {
VStack {
ProgressView()
}
.frame(minHeight: 400)
.frame(maxWidth: .infinity)
.padding()
.listRowSeparator(.hidden, edges: .all)
} else {
if !viewModel.fetcher.items.isEmpty {
ForEach(Array(viewModel.fetcher.items.enumerated()), id: \.1.id) { idx, item in
LibraryItemGridCardNavigationLink(
item: item,
viewModel: viewModel
)
.contextMenu {
menuItems(for: item)
}
.onAppear {
if idx >= viewModel.fetcher.items.count - 5 {
Task {
await viewModel.loadMore(dataService: dataService)
}
}
}
}
}
}
Spacer()
}
.frame(maxHeight: .infinity)
.padding()
.background(
GeometryReader {
Color(.systemBackground).preference(
key: ScrollViewOffsetPreferenceKey.self,
value: $0.frame(in: .global).origin.y
)
}
)
.onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { offset in
DispatchQueue.main.async {
if !viewModel.isLoading, offset > 240 {
loadItems(isRefresh: true)
}
}
}
if viewModel.fetcher.items.isEmpty {
EmptyState(viewModel: viewModel)
} else {
HStack {
Spacer()
BottomView(viewModel: viewModel).frame(maxWidth: 300)
Spacer()
}
}
if viewModel.fetcher.items.isEmpty, viewModel.isLoading {
LoadingSection()
}
}
.background(Color(.systemBackground))
Spacer()
}
}
}
#endif
struct ScrollViewOffsetPreferenceKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
#if os(iOS)
// Allows us to present a sheet without animation
// Used to configure full screen modal view coming from share extension read now button action
public extension View {
func withoutAnimation(_ completion: @escaping () -> Void) {
UIView.setAnimationsEnabled(false)
completion()
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) {
UIView.setAnimationsEnabled(true)
}
}
}
#endif
struct LinkDestination: View {
let selectedItem: Models.LibraryItem?
var body: some View {
Group {
if let selectedItem = selectedItem {
let destination = LinkItemDetailView(
linkedItemObjectID: selectedItem.objectID,
isPDF: selectedItem.isPDF
)
#if os(iOS)
let modifiedDestination = destination
.navigationTitle("")
#else
let modifiedDestination = destination
#endif
modifiedDestination
} else {
EmptyView()
}
}
}
}
func fakeLibraryItems(dataService _: DataService) -> [LibraryItemData] {
Array(
repeatElement(0, count: 20)
.map { _ in
LibraryItemData(
id: UUID().uuidString,
title: "fake title that is kind of long so it looks better",
pageURLString: "",
isArchived: false,
author: "fake author",
deepLink: nil,
hasLabels: false,
noteText: nil,
readingProgress: 10,
wordsCount: 10,
isPDF: false,
highlights: nil,
sortedLabels: [],
imageURL: nil,
publisherDisplayName: "fake publisher",
descriptionText: "This is a fake description"
)
})
}
struct BottomView: View {
@ObservedObject var viewModel: HomeFeedViewModel
@EnvironmentObject var dataService: DataService
@State var autoLoading = false
var body: some View {
innerBody
.listRowSeparator(.hidden)
.onAppear {
Task {
autoLoading = true
await viewModel.loadMore(dataService: dataService)
autoLoading = false
}
}
}
var innerBody: some View {
if viewModel.fetcher.items.count < 3 {
AnyView(Color.clear)
} else {
AnyView(HStack {
if let totalCount = viewModel.fetcher.totalCount {
Text("\(viewModel.fetcher.items.count) of \(totalCount) items.")
}
Spacer()
if viewModel.isLoading {
ProgressView()
} else {
Button(action: {
Task {
await viewModel.loadMore(dataService: dataService)
}
}, label: {
if let totalCount = viewModel.fetcher.totalCount, viewModel.fetcher.items.count >= totalCount {
Text("Check for more")
} else {
Text("Fetch more")
}
})
.foregroundColor(Color.blue)
}
}.padding(10))
}
}
}