Merge pull request #175 from omnivore-app/feature/ipad-library-grid

iPad library grid
This commit is contained in:
Satindar Dhillon 2022-03-02 10:25:50 -08:00 committed by GitHub
commit cdeaa1ffff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 479 additions and 130 deletions

View file

@ -12,6 +12,7 @@ struct FeedCardNavigationLink: View {
@Binding var selectedLinkItem: FeedItem?
@ObservedObject var viewModel: HomeFeedViewModel
var body: some View {
NavigationLink(
destination: LinkItemDetailView(viewModel: LinkItemDetailViewModel(item: item)),
@ -28,3 +29,43 @@ struct FeedCardNavigationLink: View {
FeedCard(item: item)
}
}
struct GridCardNavigationLink: View {
@EnvironmentObject var dataService: DataService
@State private var scale = 1.0
@State private var isActive = false
let item: FeedItem
let searchQuery: String
let actionHandler: (GridCardAction) -> Void
@Binding var selectedLinkItem: FeedItem?
@Binding var isContextMenuOpen: Bool
@ObservedObject var viewModel: HomeFeedViewModel
var body: some View {
ZStack {
NavigationLink(
destination: LinkItemDetailView(viewModel: LinkItemDetailViewModel(item: item)),
isActive: $isActive
) {
EmptyView()
}
.onAppear {
viewModel.itemAppeared(item: item, searchQuery: searchQuery, dataService: dataService)
}
GridCard(item: item, isContextMenuOpen: $isContextMenuOpen, actionHandler: actionHandler, tapAction: {
withAnimation {
scale = 0.95
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(150)) {
scale = 1.0
isActive = true
}
}
})
}
.scaleEffect(scale)
}
}

View file

@ -1,44 +0,0 @@
import Models
import Services
import SwiftUI
import Utils
import Views
struct FeedItemContextMenuView: View {
@EnvironmentObject var dataService: DataService
let item: FeedItem
@Binding var selectedLinkItem: FeedItem?
@Binding var snoozePresented: Bool
@Binding var itemToSnooze: FeedItem?
@ObservedObject var viewModel: HomeFeedViewModel
var body: some View {
if !item.isArchived {
Button(action: {
withAnimation(.linear(duration: 0.4)) {
viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: true)
if item == selectedLinkItem {
selectedLinkItem = nil
}
}
}, label: { Label("Archive", systemImage: "archivebox") })
} else {
Button(action: {
withAnimation(.linear(duration: 0.4)) {
viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: false)
}
}, label: { Label("Unarchive", systemImage: "tray.and.arrow.down.fill") })
}
if FeatureFlag.enableSnooze {
Button {
itemToSnooze = item
snoozePresented = true
} label: {
Label { Text("Snooze") } icon: { Image.moon }
}
}
}
}

View file

@ -7,38 +7,26 @@ import Utils
import Views
#if os(iOS)
struct CompactHomeView: View {
@ObservedObject var viewModel: HomeFeedViewModel
var body: some View {
NavigationView {
HomeFeedContainerView(viewModel: viewModel)
.toolbar {
ToolbarItem {
NavigationLink(
destination: { ProfileView() },
label: {
Image.profile
.resizable()
.frame(width: 26, height: 26)
.padding()
}
)
}
}
}
.accentColor(.appGrayTextContrast)
}
}
struct HomeFeedContainerView: View {
let isCompact: Bool
@EnvironmentObject var dataService: DataService
@State private var searchQuery = ""
@State private var snoozePresented = false
@State private var itemToSnooze: FeedItem?
@State private var selectedLinkItem: FeedItem?
@ObservedObject var viewModel: HomeFeedViewModel
var body: some View {
if #available(iOS 15.0, *) {
HomeFeedView(searchQuery: $searchQuery, viewModel: viewModel)
Group {
if #available(iOS 15.0, *) {
HomeFeedView(
isCompact: isCompact,
searchQuery: $searchQuery,
selectedLinkItem: $selectedLinkItem,
snoozePresented: $snoozePresented,
itemToSnooze: $itemToSnooze,
viewModel: viewModel
)
.refreshable {
viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true)
}
@ -61,28 +49,95 @@ import Views
.onSubmit(of: .search) {
viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true)
}
} else {
HomeFeedView(searchQuery: $searchQuery, viewModel: viewModel).toolbar {
ToolbarItem {
Button(
action: { viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) },
label: { Label("Refresh Feed", systemImage: "arrow.clockwise") }
)
} else {
HomeFeedView(
isCompact: isCompact,
searchQuery: $searchQuery,
selectedLinkItem: $selectedLinkItem,
snoozePresented: $snoozePresented,
itemToSnooze: $itemToSnooze,
viewModel: viewModel
)
.toolbar {
ToolbarItem {
Button(
action: { viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true) },
label: { Label("Refresh Feed", systemImage: "arrow.clockwise") }
)
}
}
}
}
.navigationTitle("Home")
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
// Don't refresh the list if the user is currently reading an article
if selectedLinkItem == nil {
viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true)
}
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("PushFeedItem"))) { notification in
if let feedItem = notification.userInfo?["feedItem"] as? FeedItem {
viewModel.pushFeedItem(item: feedItem)
self.selectedLinkItem = feedItem
}
}
.formSheet(isPresented: $snoozePresented) {
SnoozeView(snoozePresented: $snoozePresented, itemToSnooze: $itemToSnooze) {
viewModel.snoozeUntil(
dataService: dataService,
linkId: $0.feedItemId,
until: $0.snoozeUntilDate,
successMessage: $0.successMessage
)
}
}
.onAppear {
if viewModel.items.isEmpty {
viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true)
}
}
}
}
struct HomeFeedView: View {
let isCompact: Bool
@Binding var searchQuery: String
@Binding var selectedLinkItem: FeedItem?
@Binding var snoozePresented: Bool
@Binding var itemToSnooze: FeedItem?
@ObservedObject var viewModel: HomeFeedViewModel
var body: some View {
if isCompact {
HomeFeedListView(
searchQuery: $searchQuery,
selectedLinkItem: $selectedLinkItem,
snoozePresented: $snoozePresented,
itemToSnooze: $itemToSnooze,
viewModel: viewModel
)
} else {
HomeFeedGridView(
searchQuery: $searchQuery,
selectedLinkItem: $selectedLinkItem,
snoozePresented: $snoozePresented,
itemToSnooze: $itemToSnooze,
viewModel: viewModel
)
}
}
}
struct HomeFeedListView: View {
@EnvironmentObject var dataService: DataService
@Binding var searchQuery: String
@Binding var selectedLinkItem: FeedItem?
@Binding var snoozePresented: Bool
@Binding var itemToSnooze: FeedItem?
@State private var selectedLinkItem: FeedItem?
@State private var itemToRemove: FeedItem?
@State private var confirmationShown = false
@State private var snoozePresented = false
@State private var itemToSnooze: FeedItem?
@ObservedObject var viewModel: HomeFeedViewModel
@ -90,21 +145,38 @@ import Views
List {
Section {
ForEach(viewModel.items) { item in
let link = ZStack {
FeedCardNavigationLink(
item: item,
searchQuery: searchQuery,
selectedLinkItem: $selectedLinkItem,
viewModel: viewModel
)
}.contextMenu {
FeedItemContextMenuView(
item: item,
selectedLinkItem: $selectedLinkItem,
snoozePresented: $snoozePresented,
itemToSnooze: $itemToSnooze,
viewModel: viewModel
let link = FeedCardNavigationLink(
item: item,
searchQuery: searchQuery,
selectedLinkItem: $selectedLinkItem,
viewModel: viewModel
)
.contextMenu {
Button(action: {
withAnimation(.linear(duration: 0.4)) {
viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: !item.isArchived)
}
}, label: {
Label(
item.isArchived ? "Unarchive" : "Archive",
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
)
})
Button(
action: {
itemToRemove = item
confirmationShown = true
},
label: { Label("Delete Link", systemImage: "trash") }
)
if FeatureFlag.enableSnooze {
Button {
itemToSnooze = item
snoozePresented = true
} label: {
Label { Text("Snooze") } icon: { Image.moon }
}
}
}
if #available(iOS 15.0, *) {
link
@ -170,38 +242,71 @@ import Views
}
}
.listStyle(PlainListStyle())
.navigationTitle("Home")
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
// Don't refresh the list if the user is currently reading an article
if selectedLinkItem == nil {
refresh()
}
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("PushFeedItem"))) { notification in
if let feedItem = notification.userInfo?["feedItem"] as? FeedItem {
viewModel.pushFeedItem(item: feedItem)
self.selectedLinkItem = feedItem
}
}
.formSheet(isPresented: $snoozePresented) {
SnoozeView(snoozePresented: $snoozePresented, itemToSnooze: $itemToSnooze) {
viewModel.snoozeUntil(
dataService: dataService,
linkId: $0.feedItemId,
until: $0.snoozeUntilDate,
successMessage: $0.successMessage
)
}
}
.onAppear {
if viewModel.items.isEmpty {
refresh()
}
}
}
struct HomeFeedGridView: View {
@EnvironmentObject var dataService: DataService
@Binding var searchQuery: String
@Binding var selectedLinkItem: FeedItem?
@Binding var snoozePresented: Bool
@Binding var itemToSnooze: FeedItem?
@State private var itemToRemove: FeedItem?
@State private var confirmationShown = false
@State var isContextMenuOpen = false
@ObservedObject var viewModel: HomeFeedViewModel
private let columns = Array(repeating: GridItem(.flexible(), spacing: 20), count: 2)
func contextMenuActionHandler(item: FeedItem, action: GridCardAction) {
switch action {
case .toggleArchiveStatus:
viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: !item.isArchived)
case .delete:
itemToRemove = item
confirmationShown = true
}
}
private func refresh() {
viewModel.loadItems(dataService: dataService, searchQuery: searchQuery, isRefresh: true)
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 20) {
ForEach(viewModel.items) { item in
let link = GridCardNavigationLink(
item: item,
searchQuery: searchQuery,
actionHandler: { contextMenuActionHandler(item: item, action: $0) },
selectedLinkItem: $selectedLinkItem,
isContextMenuOpen: $isContextMenuOpen,
viewModel: viewModel
)
if #available(iOS 15.0, *) {
link
.alert("Are you sure?", isPresented: $confirmationShown) {
Button("Remove Link", role: .destructive) {
if let itemToRemove = itemToRemove {
withAnimation {
viewModel.removeLink(dataService: dataService, linkId: itemToRemove.id)
}
}
self.itemToRemove = nil
}
Button("Cancel", role: .cancel) { self.itemToRemove = nil }
}
} else {
link
}
}
}
.padding()
.background(Color(.systemGroupedBackground))
if viewModel.isLoading {
LoadingSection()
}
}
}
}

View file

@ -22,19 +22,54 @@ import Views
List {
Section {
ForEach(viewModel.items) { item in
ZStack {
if #available(macOS 12.0, *) {
FeedCardNavigationLink(
item: item,
searchQuery: searchQuery,
selectedLinkItem: $selectedLinkItem,
viewModel: viewModel
)
}.contextMenu {
FeedItemContextMenuView(
.contextMenu {
Button(action: {
viewModel.setLinkArchived(dataService: dataService, linkId: item.id, archived: !item.isArchived)
}, label: {
Label(
item.isArchived ? "Unarchive" : "Archive",
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
)
})
Button(
action: {
itemToRemove = item
confirmationShown = true
},
label: { Label("Delete Link", systemImage: "trash") }
)
if FeatureFlag.enableSnooze {
Button {
itemToSnooze = item
snoozePresented = true
} label: {
Label { Text("Snooze") } icon: { Image.moon }
}
}
}
.alert("Are you sure?", isPresented: $confirmationShown) {
Button("Remove Link", role: .destructive) {
if let itemToRemove = itemToRemove {
withAnimation {
viewModel.removeLink(dataService: dataService, linkId: itemToRemove.id)
self.itemToRemove = nil
}
}
}
Button("Cancel", role: .cancel) { self.itemToRemove = nil }
}
} else {
FeedCardNavigationLink(
item: item,
searchQuery: searchQuery,
selectedLinkItem: $selectedLinkItem,
snoozePresented: $snoozePresented,
itemToSnooze: $itemToSnooze,
viewModel: viewModel
)
}

View file

@ -6,9 +6,27 @@ struct HomeView: View {
var body: some View {
#if os(iOS)
if UIDevice.isIPhone {
CompactHomeView(viewModel: viewModel)
NavigationView {
HomeFeedContainerView(isCompact: true, viewModel: viewModel)
.toolbar {
ToolbarItem {
NavigationLink(
destination: { ProfileView() },
label: {
Image.profile
.resizable()
.frame(width: 26, height: 26)
.padding()
}
)
}
}
}
.accentColor(.appGrayTextContrast)
} else {
HomeFeedContainerView(viewModel: viewModel)
GeometryReader { geo in
HomeFeedContainerView(isCompact: geo.size.width < 500, viewModel: viewModel)
}
}
#elseif os(macOS)
HomeFeedView(viewModel: viewModel)

View file

@ -14,4 +14,5 @@ public enum FeatureFlag {
public static let enablePushNotifications = false
public static let enableShareButton = false
public static let enableSnooze = false
public static let showFeedItemTags = false
}

View file

@ -21,8 +21,12 @@ public extension Color {
#if os(iOS)
static var systemBackground: Color { Color(.systemBackground) }
static var systemPlaceholder: Color { Color(.placeholderText) }
static var secondarySystemGroupedBackground: Color { Color(.secondarySystemGroupedBackground) }
#elseif os(macOS)
static var systemBackground: Color { Color(.windowBackgroundColor) }
static var systemPlaceholder: Color { Color(.placeholderTextColor) }
// Just for compilation. secondarySystemGroupedBackground shouldn't be used on macOS
static var secondarySystemGroupedBackground: Color { Color(.windowBackgroundColor) }
#endif
}

View file

@ -0,0 +1,166 @@
import Models
import SwiftUI
import Utils
public enum GridCardAction {
case toggleArchiveStatus
case delete
}
public struct GridCard: View {
@Binding var isContextMenuOpen: Bool
let item: FeedItem
let actionHandler: (GridCardAction) -> Void
let tapAction: () -> Void
public init(
item: FeedItem,
isContextMenuOpen: Binding<Bool>,
actionHandler: @escaping (GridCardAction) -> Void,
tapAction: @escaping () -> Void
) {
self.item = item
self._isContextMenuOpen = isContextMenuOpen
self.actionHandler = actionHandler
self.tapAction = tapAction
}
// 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
} else {
tapAction()
}
}
func menuActionHandler(_ action: GridCardAction) {
isContextMenuOpen = false
actionHandler(action)
}
var contextMenuView: some View {
Group {
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 Link", systemImage: "trash") }
)
}
}
public var body: some View {
VStack(alignment: .leading, spacing: 16) {
// Progress Bar
Group {
// Remove os check when dropping macOS 11
#if os(iOS)
if #available(iOS 15.0, *) {
ProgressView(value: min(abs(item.readingProgress) / 100, 1))
.tint(.appYellow48)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
ProgressView(value: max(abs(item.readingProgress) / 100, 1))
.frame(maxWidth: .infinity, alignment: .leading)
}
#else
ProgressView(value: max(abs(item.readingProgress) / 100, 1))
.frame(maxWidth: .infinity, alignment: .leading)
#endif
}
.onTapGesture { tapHandler() }
// Title, Subtitle, Menu Button
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(item.title)
.font(.appHeadline)
.foregroundColor(.appGrayTextContrast)
.lineLimit(1)
.onTapGesture { tapHandler() }
Spacer()
Menu(
content: { contextMenuView },
label: { Image.profile }
)
.onTapGesture { isContextMenuOpen = true }
}
HStack {
if let author = item.author {
Text("by \(author)")
.font(.appCaptionTwo)
.foregroundColor(.appGrayText)
.lineLimit(1)
}
if let publisherURL = item.publisherHostname {
Text(publisherURL)
.font(.appCaptionTwo)
.foregroundColor(.appGrayText)
.underline()
.lineLimit(1)
}
Spacer()
}
.onTapGesture { tapHandler() }
}
.frame(height: 30)
.padding(.horizontal)
// Link description and image
HStack(alignment: .top) {
Text(item.description ?? item.title)
.font(.appFootnote)
.foregroundColor(.appGrayTextContrast)
.lineLimit(nil)
.multilineTextAlignment(.leading)
Spacer()
if let imageURL = item.imageURL {
AsyncImage(url: imageURL, isResizable: true)
.aspectRatio(1, contentMode: .fill)
.frame(width: 135, height: 90)
.cornerRadius(3)
}
}
.frame(height: 95)
.padding(.horizontal)
.onTapGesture { tapHandler() }
// Category Labels
if FeatureFlag.showFeedItemTags {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
TextChip(text: "label", color: .red)
TextChip(text: "longer label", color: .blue)
Spacer()
}
.frame(height: 30)
.padding(.horizontal)
.padding(.bottom, 8)
}
} else {
Spacer(minLength: 8)
}
}
.background(
Color.secondarySystemGroupedBackground
.onTapGesture { tapHandler() }
)
.cornerRadius(6)
.contextMenu { contextMenuView }
}
}

View file

@ -1,5 +1,6 @@
import Models
import SwiftUI
import Utils
public struct FeedCard: View {
let item: FeedItem

View file

@ -0,0 +1,22 @@
import SwiftUI
struct TextChip: View {
let text: String
let color: Color
let cornerRadius = 20.0
var body: some View {
Text(text)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.font(.appFootnote)
.foregroundColor(color)
.lineLimit(1)
.background(color.opacity(0.1))
.cornerRadius(cornerRadius)
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.stroke(color.opacity(0.3), lineWidth: 1)
)
}
}