diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift index 7fd196068..093310ca1 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedCardNavigationLink.swift @@ -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) + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedItemContextMenuView.swift b/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedItemContextMenuView.swift deleted file mode 100644 index 6441e5454..000000000 --- a/apple/OmnivoreKit/Sources/App/Views/Home/Components/FeedItemContextMenuView.swift +++ /dev/null @@ -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 } - } - } - } -} diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 69d3f436f..95832a2d0 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -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() + } + } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift index 56aa8dee5..2018fe402 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewMac.swift @@ -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 ) } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift index cc074c995..62181c412 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift @@ -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) diff --git a/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift b/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift index 43385fcc4..67a1faf37 100644 --- a/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift +++ b/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift @@ -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 } diff --git a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift index 6111d40d6..e3270b61c 100644 --- a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift +++ b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift @@ -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 } diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift new file mode 100644 index 000000000..9049024b1 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift @@ -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, + 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 } + } +} diff --git a/apple/OmnivoreKit/Sources/Views/HomeFeedCardView.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift similarity index 99% rename from apple/OmnivoreKit/Sources/Views/HomeFeedCardView.swift rename to apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift index 310759e6c..5f917ae30 100644 --- a/apple/OmnivoreKit/Sources/Views/HomeFeedCardView.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift @@ -1,5 +1,6 @@ import Models import SwiftUI +import Utils public struct FeedCard: View { let item: FeedItem diff --git a/apple/OmnivoreKit/Sources/Views/TextChip.swift b/apple/OmnivoreKit/Sources/Views/TextChip.swift new file mode 100644 index 000000000..775ad6854 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/TextChip.swift @@ -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) + ) + } +}