From ca511c304185f54fce2047446e58aca505ab9d4d Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 27 Dec 2023 16:04:32 +0800 Subject: [PATCH] Grid improvements for infinite scroll --- .../Home/Components/LibraryItemFetcher.swift | 1 - .../LibraryItemListNavigationLink.swift | 16 +- .../App/Views/Home/HomeFeedViewIOS.swift | 44 ++-- .../App/Views/Home/HomeFeedViewModel.swift | 31 +-- .../Sources/Views/FeedItem/GridCard.swift | 200 +++++++++++------- 5 files changed, 171 insertions(+), 121 deletions(-) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryItemFetcher.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryItemFetcher.swift index 446166546..5b78e6c92 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryItemFetcher.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryItemFetcher.swift @@ -137,7 +137,6 @@ import Views // this can occur if there are non-contiguous items in our list causing older // items to be synced back into those "holes" in the list if let cursor = cursor, let currentCursor = Int(cursor) { - print("IDX: ", idx, " CURSOR: ", cursor, " CURRENT CURSOR: ", currentCursor) if currentCursor > idx { useCursor = currentCursor.description } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryItemListNavigationLink.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryItemListNavigationLink.swift index 582c7ee7b..02b4514a0 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryItemListNavigationLink.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/LibraryItemListNavigationLink.swift @@ -62,11 +62,6 @@ struct LibraryItemGridCardNavigationLink: View { @State private var scale = 1.0 @ObservedObject var item: Models.LibraryItem - - let actionHandler: (GridCardAction) -> Void - - @Binding var isContextMenuOpen: Bool - @ObservedObject var viewModel: HomeFeedViewModel var body: some View { @@ -83,19 +78,12 @@ struct LibraryItemGridCardNavigationLink: View { isPDF: item.isPDF ) }, label: { - GridCard(item: LibraryItemData.make(from: item), isContextMenuOpen: $isContextMenuOpen, actionHandler: actionHandler) + GridCard(item: LibraryItemData.make(from: item)) } ) .buttonStyle(.plain) .aspectRatio(1.0, contentMode: .fill) - .background( - Color.secondarySystemGroupedBackground - .onTapGesture { - if isContextMenuOpen { - isContextMenuOpen = false - } - } - ) + .background(Color.systemBackground) .cornerRadius(6) } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 6170e1158..d68a68d15 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -876,6 +876,10 @@ struct AnimatingCellHeight: AnimatableModifier { FiltersHeader(viewModel: viewModel) } + 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) @@ -896,39 +900,37 @@ struct AnimatingCellHeight: AnimatableModifier { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 325, maximum: 400), spacing: 16)], alignment: .center, spacing: 30) { if viewModel.showLoadingBar { - ForEach(Array(fakeLibraryItems(dataService: dataService).enumerated()), id: \.1.id) { _, item in - GridCard(item: item, isContextMenuOpen: $isContextMenuOpen, actionHandler: { _ in - - }) + ForEach(fakeLibraryItems(dataService: dataService), id: \.id) { item in + GridCard(item: item) .aspectRatio(1.0, contentMode: .fill) - .background( - Color.secondarySystemGroupedBackground - .onTapGesture { - if isContextMenuOpen { - isContextMenuOpen = false - } - } - ) + .background(Color.systemBackground) .cornerRadius(6) }.redacted(reason: .placeholder) } else { - ForEach(viewModel.fetcher.items) { item in + ForEach(Array(viewModel.fetcher.items.enumerated()), id: \.1.id) { idx, item in LibraryItemGridCardNavigationLink( item: item, - actionHandler: { contextMenuActionHandler(item: item, action: $0) }, - isContextMenuOpen: $isContextMenuOpen, viewModel: viewModel ) + .contextMenu { + menuItems(for: item) + } + .onAppear { + if idx >= viewModel.fetcher.items.count - 5 { + Task { + await viewModel.loadMore(dataService: dataService) + } + } + } } } - BottomView(viewModel: viewModel) Spacer() } .frame(maxHeight: .infinity) .padding() .background( GeometryReader { - Color(.systemGroupedBackground).preference( + Color(.systemBackground).preference( key: ScrollViewOffsetPreferenceKey.self, value: $0.frame(in: .global).origin.y ) @@ -942,11 +944,17 @@ struct AnimatingCellHeight: AnimatableModifier { } } + HStack { + Spacer() + BottomView(viewModel: viewModel).frame(maxWidth: 300) + Spacer() + } + if viewModel.fetcher.items.isEmpty, viewModel.isLoading { LoadingSection() } } - .background(Color(.systemGroupedBackground)) + .background(Color(.systemBackground)) Spacer() } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift index 013e7d9cb..f03cc028e 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewModel.swift @@ -8,11 +8,9 @@ import Views @MainActor final class HomeFeedViewModel: NSObject, ObservableObject { let filterKey: String @ObservedObject var fetcher: LibraryItemFetcher - - private var fetchedResultsController: NSFetchedResultsController? + let folderConfigs: [String: LibraryListConfig] @Published var isLoading = false - @Published var showPushNotificationPrimer = false @Published var itemUnderLabelEdit: Models.LibraryItem? @Published var itemUnderTitleEdit: Models.LibraryItem? @Published var itemForHighlightsView: Models.LibraryItem? @@ -35,20 +33,19 @@ import Views @Published var appliedSort = LinkedItemSort.newest.rawValue @State var lastMoreFetched: Date? + @State var lastFiltersFetched: Date? @AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false @AppStorage("LibraryTabView::hideFollowingTab") var hideFollowingTab = false @Published var appliedFilter: InternalFilter? { didSet { - if let filterName = appliedFilter?.name { + if let filterName = appliedFilter?.name.lowercased() { UserDefaults.standard.setValue(filterName, forKey: filterKey) } } } - let folderConfigs: [String: LibraryListConfig] - init(filterKey: String, fetcher: LibraryItemFetcher, folderConfigs: [String: LibraryListConfig]) { self.filterKey = filterKey @@ -84,9 +81,15 @@ import Views } func loadFilters(dataService: DataService) async { + let start = Date() var hasLocalResults = false let fetchRequest: NSFetchRequest = Filter.fetchRequest() + if let lastFiltersFetched, lastFiltersFetched.timeIntervalSinceNow > -100 { + print("skipping fetching filters as last fetch was too recent: ", lastFiltersFetched) + return + } + // Load from disk if let results = try? dataService.viewContext.fetch(fetchRequest) { hasLocalResults = true @@ -94,13 +97,13 @@ import Views } let hasResults = hasLocalResults - Task.detached { - if let downloadedFilters = try? await dataService.filters() { - await self.updateFilters(newFilters: downloadedFilters) - } else if !hasResults { - await self.updateFilters(newFilters: InternalFilter.DefaultInboxFilters) - } + if let downloadedFilters = try? await dataService.filters() { + updateFilters(newFilters: downloadedFilters) + } else if !hasResults { + updateFilters(newFilters: InternalFilter.DefaultInboxFilters) } + + lastFiltersFetched = start } func loadMore(dataService: DataService, loadCursor: String? = nil) async { @@ -160,8 +163,6 @@ import Views if let newFilter = filters.first(where: { $0.name.lowercased() == appliedFilterName }), newFilter.id != appliedFilter?.id { appliedFilter = newFilter - } else { - appliedFilter = filters.first(where: { availableFolders.contains($0.folder) }) } } @@ -171,8 +172,8 @@ import Views dataService: dataService, filterState: filterState ) + objectWillChange.send() } - objectWillChange.send() } func loadItems(dataService: DataService, isRefresh: Bool, forceRemote: Bool = false) async { diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift index 782c7d735..69631117c 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift @@ -11,62 +11,12 @@ public enum GridCardAction { } public struct GridCard: View { - @Binding var isContextMenuOpen: Bool let item: LibraryItemData - let actionHandler: (GridCardAction) -> Void - // let tapAction: () -> Void public init( - item: LibraryItemData, - isContextMenuOpen: Binding, - actionHandler: @escaping (GridCardAction) -> Void + item: LibraryItemData ) { self.item = item - self._isContextMenuOpen = isContextMenuOpen - self.actionHandler = actionHandler - } - - // Menu doesn't provide an API to observe it's open state - // so we have keep track of it's state manually - func tapHandler() { - if isContextMenuOpen { - isContextMenuOpen = false - } - } - - func menuActionHandler(_ action: GridCardAction) { - isContextMenuOpen = false - actionHandler(action) - } - - var contextMenuView: some View { - Group { - Button( - action: { menuActionHandler(.viewHighlights) }, - label: { Label("Notebook", systemImage: "highlighter") } - ) - Button( - action: { menuActionHandler(.editTitle) }, - label: { Label("Edit Info", systemImage: "info.circle") } - ) - Button( - action: { menuActionHandler(.editLabels) }, - label: { Label(item.sortedLabels.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") } - ) - Button( - action: { menuActionHandler(.toggleArchiveStatus) }, - label: { - Label( - item.isArchived ? "Unarchive" : "Archive", - systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox" - ) - } - ) - Button( - action: { menuActionHandler(.delete) }, - label: { Label("Delete", systemImage: "trash") } - ) - } } var imageBox: some View { @@ -159,6 +109,117 @@ public struct GridCard: View { } } + var readingSpeed: Int64 { + var result = UserDefaults.standard.integer(forKey: UserDefaultKey.userWordsPerMinute.rawValue) + if result <= 0 { + result = 235 + } + return Int64(result) + } + + var estimatedReadingTime: String { + if item.wordsCount > 0 { + let readLen = max(1, item.wordsCount / readingSpeed) + return "\(readLen) MIN READ • " + } + return "" + } + + var readingProgress: String { + // If there is no wordsCount don't show progress because it will make no sense + if item.wordsCount > 0 { + return "\(String(format: "%d", Int(item.readingProgress)))%" + } + if item.isPDF { + // base estimated reading time on page count + return "\(String(format: "%d", Int(item.readingProgress)))%" + } + return "" + } + + var hasMultipleInfoItems: Bool { + item.wordsCount > 0 || item.highlights?.first { ($0 as? Highlight)?.annotation != nil } != nil + } + + var highlightsText: String { + if let highlights = item.highlights, highlights.count > 0 { + let fmted = LocalText.pluralizedText(key: "number_of_highlights", count: highlights.count) + if item.wordsCount > 0 || item.isPDF { + return " • \(fmted)" + } + return fmted + } + return "" + } + + var notesText: String { + let notes = item.highlights?.filter { item in + if let highlight = item as? Highlight { + return !(highlight.annotation ?? "").isEmpty + } + return false + } + + if let notes = notes, notes.count > 0 { + let fmted = LocalText.pluralizedText(key: "number_of_notes", count: notes.count) + if hasMultipleInfoItems { + return " • \(fmted)" + } + return fmted + } + return "" + } + + var flairLabels: [FlairLabels] { + item.sortedLabels.compactMap { label in + if let name = label.name { + return FlairLabels(rawValue: name.lowercased()) + } + return nil + }.sorted { $0.sortOrder < $1.sortOrder } + } + + var isPartiallyRead: Bool { + Int(item.readingProgress) > 0 + } + + var nonFlairLabels: [LinkedItemLabel] { + item.sortedLabels.filter { label in + if let name = label.name, FlairLabels(rawValue: name.lowercased()) != nil { + return false + } + return true + } + } + + var readInfo: some View { + HStack(alignment: .center, spacing: 5.0) { + ForEach(flairLabels, id: \.self) { + $0.icon + } + + Text("\(estimatedReadingTime)") + .font(.caption2).fontWeight(.medium) + .foregroundColor(Color.themeLibraryItemSubtle) + + + + Text("\(readingProgress)") + .font(.caption2).fontWeight(.medium) + .foregroundColor(isPartiallyRead ? Color.appGreenSuccess : Color.themeLibraryItemSubtle) + + + + Text("\(highlightsText)") + .font(.caption2).fontWeight(.medium) + .foregroundColor(Color.themeLibraryItemSubtle) + + + + Text("\(notesText)") + .font(.caption2).fontWeight(.medium) + .foregroundColor(Color.themeLibraryItemSubtle) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + public var body: some View { GeometryReader { geo in VStack(alignment: .leading, spacing: 0) { @@ -167,17 +228,19 @@ public struct GridCard: View { .frame(height: geo.size.height / 2.0) VStack(alignment: .leading, spacing: 4) { - HStack { - Text(item.title) - .font(.appHeadline) - .foregroundColor(.appGrayTextContrast) - .lineLimit(1) - } + readInfo + .dynamicTypeSize(.xSmall ... .medium) + .padding(.horizontal, 15) + + Text(item.title) + .lineLimit(2) + .font(.appHeadline) + .foregroundColor(.appGrayTextContrast) + .padding(.horizontal, 15) byLine + .padding(.horizontal, 15) } - .frame(height: 30) - .padding(.horizontal, 10) .padding(.bottom, 10) .padding(.top, 10) @@ -186,30 +249,21 @@ public struct GridCard: View { Text(item.descriptionText ?? item.title) .font(.appSubheadline) .foregroundColor(.appGrayTextContrast) - .lineLimit(3) + .lineLimit(2) .multilineTextAlignment(.leading) Spacer() } - .padding(.horizontal, 10) + .padding(.horizontal, 15) - // Category Labels - if item.hasLabels { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - ForEach(item.sortedLabels, id: \.self) { - TextChip(feedItemLabel: $0) - } - Spacer() - } - .padding(.horizontal, 10) - } + if !nonFlairLabels.isEmpty { + LabelsFlowLayout(labels: nonFlairLabels) + .padding(.horizontal, 15) } } .padding(.horizontal, 0) .padding(.top, 0) } - .contextMenu { contextMenuView } } } }