mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Grid improvements for infinite scroll
This commit is contained in:
parent
f47e53f56b
commit
ca511c3041
5 changed files with 171 additions and 121 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,9 @@ import Views
|
|||
@MainActor final class HomeFeedViewModel: NSObject, ObservableObject {
|
||||
let filterKey: String
|
||||
@ObservedObject var fetcher: LibraryItemFetcher
|
||||
|
||||
private var fetchedResultsController: NSFetchedResultsController<Models.LibraryItem>?
|
||||
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<Models.Filter> = 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 {
|
||||
|
|
|
|||
|
|
@ -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<Bool>,
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue