Merge branch 'swiftui' into swiftui

This commit is contained in:
Maurice Parker
2020-07-02 04:53:43 -05:00
committed by GitHub
18 changed files with 492 additions and 79 deletions

View File

@@ -104,6 +104,16 @@ struct AppAssets {
#endif
}()
static var timelineStarred: Image = {
return Image(systemName: "star.fill")
}()
static var timelineUnread: Image = {
return Image(systemName: "circle.fill")
}()
static var todayFeedImage: IconImage = {
#if os(macOS)
return IconImage(NSImage(systemSymbolName: "sun.max.fill", accessibilityDescription: nil)!)

View File

@@ -0,0 +1,59 @@
//
// ArticleIconImageLoader.swift
// NetNewsWire
//
// Created by Maurice Parker on 7/1/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import Account
import Articles
final class ArticleIconImageLoader: ObservableObject {
private var article: Article?
@Published var image: IconImage?
init() {
NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(webFeedIconDidBecomeAvailable(_:)), name: .WebFeedIconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil)
}
func loadImage(for article: Article) {
guard image == nil else { return }
self.article = article
image = article.iconImage()
}
}
private extension ArticleIconImageLoader {
@objc func faviconDidBecomeAvailable(_ note: Notification) {
guard let article = article else { return }
image = article.iconImage()
}
@objc func webFeedIconDidBecomeAvailable(_ note: Notification) {
guard let article = article, let noteFeed = note.userInfo?[UserInfoKey.webFeed] as? WebFeed, noteFeed == article.webFeed else {
return
}
image = article.iconImage()
}
@objc func avatarDidBecomeAvailable(_ note: Notification) {
guard let article = article, let authors = article.authors, let avatarURL = note.userInfo?[UserInfoKey.url] as? String else {
return
}
for author in authors {
if author.avatarURL == avatarURL {
image = article.iconImage()
return
}
}
}
}

View File

@@ -1,5 +1,5 @@
//
// FeedImageLoader.swift
// FeedIconImageLoader.swift
// NetNewsWire
//
// Created by Maurice Parker on 6/29/20.
@@ -9,7 +9,7 @@
import SwiftUI
import Account
final class FeedImageLoader: ObservableObject {
final class FeedIconImageLoader: ObservableObject {
private var feed: Feed?
@@ -21,8 +21,27 @@ final class FeedImageLoader: ObservableObject {
}
func loadImage(for feed: Feed) {
guard image == nil else { return }
self.feed = feed
fetchImage()
}
}
private extension FeedIconImageLoader {
@objc func faviconDidBecomeAvailable(_ note: Notification) {
fetchImage()
}
@objc func webFeedIconDidBecomeAvailable(_ note: Notification) {
guard let feed = feed as? WebFeed, let noteFeed = note.userInfo?[UserInfoKey.webFeed] as? WebFeed, feed == noteFeed else {
return
}
fetchImage()
}
func fetchImage() {
if let webFeed = feed as? WebFeed {
if let feedIconImage = appDelegate.webFeedIconDownloader.icon(for: webFeed) {
image = feedIconImage
@@ -40,19 +59,3 @@ final class FeedImageLoader: ObservableObject {
}
}
private extension FeedImageLoader {
@objc func faviconDidBecomeAvailable(_ note: Notification) {
guard let feed = feed else { return }
loadImage(for: feed)
}
@objc func webFeedIconDidBecomeAvailable(_ note: Notification) {
guard let feed = feed as? WebFeed, let noteFeed = note.userInfo?[UserInfoKey.webFeed] as? WebFeed, feed == noteFeed else {
return
}
loadImage(for: feed)
}
}

View File

@@ -13,20 +13,10 @@ struct IconImageView: View {
var iconImage: IconImage
var body: some View {
#if os(macOS)
return Image(nsImage: iconImage.image)
return Image(rsImage: iconImage.image)
.resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
.cornerRadius(4)
#endif
#if os(iOS)
return Image(uiImage: iconImage.image)
.resizable()
.scaledToFit()
.frame(width: 20, height: 20, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
.cornerRadius(4)
#endif
}
}

View File

@@ -0,0 +1,58 @@
//
// PreviewArticles.swift
// NetNewsWire
//
// Created by Maurice Parker on 7/1/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import Articles
enum PreviewArticles {
static var basicUnread: Article {
return makeBasicArticle(read: false, starred: false)
}
static var basicRead: Article {
return makeBasicArticle(read: true, starred: false)
}
static var basicStarred: Article {
return makeBasicArticle(read: false, starred: true)
}
}
private extension PreviewArticles {
static var shortTitle: String {
return "Short article title"
}
static var shortSummary: String {
return "Summary of article to be shown after title."
}
static func makeBasicArticle(read: Bool, starred: Bool) -> Article {
let articleID = "prototype"
let status = ArticleStatus(articleID: articleID, read: read, starred: starred, dateArrived: Date())
return Article(accountID: articleID,
articleID: articleID,
webFeedID: articleID,
uniqueID: articleID,
title: shortTitle,
contentHTML: nil,
contentText: nil,
url: nil,
externalURL: nil,
summary: shortSummary,
imageURL: nil,
datePublished: Date(),
dateModified: nil,
authors: nil,
status: status)
}
}

View File

@@ -20,10 +20,6 @@ final class SceneModel: ObservableObject {
extension SceneModel: SidebarModelDelegate {
func sidebarSelectionDidChange(_: SidebarModel, feeds: [Feed]?) {
print("**** sidebar selection changed ***")
}
func unreadCount(for feed: Feed) -> Int {
// TODO: Get the count from the timeline if Feed is the current timeline
return feed.unreadCount

View File

@@ -29,11 +29,7 @@ final class SidebarExpandedContainers: ObservableObject {
subscript(_ containerID: ContainerIdentifier) -> Bool {
get {
if expandedTable.contains(containerID) {
return true
} else {
return false
}
return expandedTable.contains(containerID)
}
set(newValue) {
if newValue {

View File

@@ -11,13 +11,14 @@ import Account
struct SidebarItemView: View {
@StateObject var feedImageLoader = FeedImageLoader()
@StateObject var feedIconImageLoader = FeedIconImageLoader()
var sidebarItem: SidebarItem
var body: some View {
HStack {
if let image = feedImageLoader.image {
if let image = feedIconImageLoader.image {
IconImageView(iconImage: image)
.frame(width: 20, height: 20, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
}
Text(verbatim: sidebarItem.nameForDisplay)
Spacer()
@@ -27,7 +28,7 @@ struct SidebarItemView: View {
}
.onAppear {
if let feed = sidebarItem.feed {
feedImageLoader.loadImage(for: feed)
feedIconImageLoader.loadImage(for: feed)
}
}.contextMenu(menuItems: {
menuItems

View File

@@ -11,7 +11,6 @@ import RSCore
import Account
protocol SidebarModelDelegate: class {
func sidebarSelectionDidChange(_: SidebarModel, feeds: [Feed]?)
func unreadCount(for: Feed) -> Int
}

View File

@@ -16,8 +16,6 @@ struct SidebarView: View {
@StateObject private var expandedContainers = SidebarExpandedContainers()
@EnvironmentObject private var sidebarModel: SidebarModel
// @State private var selected = Set<FeedIdentifier>()
var body: some View {
List() {
ForEach(sidebarModel.sidebarItems) { sidebarItem in
@@ -27,13 +25,19 @@ struct SidebarView: View {
if let containerID = sidebarItem.containerID {
DisclosureGroup(isExpanded: $expandedContainers[containerID]) {
ForEach(sidebarItem.children) { sidebarItem in
SidebarItemView(sidebarItem: sidebarItem)
NavigationLink(destination: (TimelineContainerView(feed: sidebarItem.feed))) {
SidebarItemView(sidebarItem: sidebarItem)
}
}
} label: {
SidebarItemView(sidebarItem: sidebarItem)
NavigationLink(destination: (TimelineContainerView(feed: sidebarItem.feed))) {
SidebarItemView(sidebarItem: sidebarItem)
}
}
} else {
SidebarItemView(sidebarItem: sidebarItem)
NavigationLink(destination: (TimelineContainerView(feed: sidebarItem.feed))) {
SidebarItemView(sidebarItem: sidebarItem)
}
}
}
} label: {

View File

@@ -0,0 +1,23 @@
//
// Image-Extensions.swift
// NetNewsWire
//
// Created by Maurice Parker on 7/1/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import RSCore
extension Image {
init(rsImage: RSImage) {
#if os(macOS)
self = Image(nsImage: rsImage)
#endif
#if os(iOS)
self = Image(uiImage: rsImage)
#endif
}
}

View File

@@ -7,28 +7,26 @@
//
import SwiftUI
import Account
struct TimelineContainerView: View {
@EnvironmentObject private var sceneModel: SceneModel
@StateObject private var timelineModel = TimelineModel()
var feed: Feed? = nil
@ViewBuilder var body: some View {
TimelineView()
.environmentObject(timelineModel)
.listStyle(SidebarListStyle())
.onAppear {
sceneModel.timelineModel = timelineModel
timelineModel.delegate = sceneModel
timelineModel.rebuildTimelineItems()
}
if let feed = feed {
TimelineView()
.environmentObject(timelineModel)
.onAppear {
sceneModel.timelineModel = timelineModel
timelineModel.delegate = sceneModel
timelineModel.rebuildTimelineItems(feed)
}
} else {
EmptyView()
}
}
}
struct TimelineContainerView_Previews: PreviewProvider {
static var previews: some View {
TimelineContainerView()
.environmentObject(SceneModel())
}
}

View File

@@ -9,9 +9,36 @@
import SwiftUI
import Articles
enum TimelineItemStatus {
case showStar
case showUnread
case showNone
}
struct TimelineItem: Identifiable {
var id: String
var article: Article
var id: String {
return article.articleID
}
var status: TimelineItemStatus {
if article.status.starred == true {
return .showStar
}
if article.status.read == false {
return .showUnread
}
return .showNone
}
var byline: String {
return article.webFeed?.nameForDisplay ?? ""
}
var dateTimeString: String {
return ArticleStringFormatter.dateString(article.logicalDatePublished)
}
}

View File

@@ -0,0 +1,43 @@
//
// TimelineItemStatusView.swift
// NetNewsWire
//
// Created by Maurice Parker on 7/1/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
struct TimelineItemStatusView: View {
var status: TimelineItemStatus
@ViewBuilder var statusView: some View {
switch status {
case .showUnread:
AppAssets.timelineUnread
.resizable()
.frame(width: 8, height: 8, alignment: .center)
.padding(.all, 2)
.foregroundColor(.accentColor)
case .showStar:
AppAssets.timelineStarred
.resizable()
.frame(width: 10, height: 10, alignment: .center)
.foregroundColor(.yellow)
case .showNone:
AppAssets.timelineUnread
.resizable()
.frame(width: 8, height: 8, alignment: .center)
.padding(.all, 2)
.opacity(0)
}
}
var body: some View {
statusView
.padding(.top, 4)
.padding(.leading, 4)
}
}

View File

@@ -0,0 +1,65 @@
//
// TimelineItemView.swift
// NetNewsWire
//
// Created by Maurice Parker on 7/1/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
struct TimelineItemView: View {
@StateObject var articleIconImageLoader = ArticleIconImageLoader()
var timelineItem: TimelineItem
var body: some View {
VStack {
HStack(alignment: .top) {
TimelineItemStatusView(status: timelineItem.status)
if let image = articleIconImageLoader.image {
IconImageView(iconImage: image)
.frame(width: AppDefaults.timelineIconSize.size.width, height: AppDefaults.timelineIconSize.size.height, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
}
VStack {
Text(verbatim: timelineItem.article.title ?? "N/A")
.fontWeight(.semibold)
.lineLimit(AppDefaults.timelineNumberOfLines)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, 4)
Spacer()
HStack {
Text(verbatim: timelineItem.byline)
.lineLimit(1)
.truncationMode(.tail)
.font(.footnote)
.foregroundColor(.secondary)
Spacer()
Text(verbatim: timelineItem.dateTimeString)
.lineLimit(1)
.font(.footnote)
.foregroundColor(.secondary)
.padding(.trailing, 4)
}
}
}
Divider()
}
.onAppear {
articleIconImageLoader.loadImage(for: timelineItem.article)
}
}
}
struct TimelineItemView_Previews: PreviewProvider {
static var previews: some View {
Group {
TimelineItemView(timelineItem: TimelineItem(article: PreviewArticles.basicRead))
.frame(maxWidth: 250)
TimelineItemView(timelineItem: TimelineItem(article: PreviewArticles.basicUnread))
.frame(maxWidth: 250)
TimelineItemView(timelineItem: TimelineItem(article: PreviewArticles.basicStarred))
.frame(maxWidth: 250)
}
}
}

View File

@@ -9,6 +9,7 @@
import Foundation
import RSCore
import Account
import Articles
protocol TimelineModelDelegate: class {
func timelineRequestedWebFeedSelection(_: TimelineModel, webFeed: WebFeed)
@@ -20,19 +21,103 @@ class TimelineModel: ObservableObject {
@Published var timelineItems = [TimelineItem]()
private var feeds = [Feed]()
private var fetchSerialNumber = 0
private let fetchRequestQueue = FetchRequestQueue()
private var exceptionArticleFetcher: ArticleFetcher?
private var isReadFiltered = false
private var articles = [Article]()
private var sortDirection = AppDefaults.timelineSortDirection {
didSet {
if sortDirection != oldValue {
sortParametersDidChange()
}
}
}
private var groupByFeed = AppDefaults.timelineGroupByFeed {
didSet {
if groupByFeed != oldValue {
sortParametersDidChange()
}
}
}
init() {
}
// MARK: API
func rebuildTimelineItems() {
func rebuildTimelineItems(_ feed: Feed) {
feeds = [feed]
fetchAndReplaceArticlesAsync()
}
}
// MARK: Private
private extension TimelineModel {
func sortParametersDidChange() {
performBlockAndRestoreSelection {
let unsortedArticles = Set(articles)
replaceArticles(with: unsortedArticles)
}
}
func performBlockAndRestoreSelection(_ block: (() -> Void)) {
// let savedSelection = selectedArticleIDs()
block()
// restoreSelection(savedSelection)
}
// MARK: Article Fetching
func fetchAndReplaceArticlesAsync() {
cancelPendingAsyncFetches()
var fetchers = feeds as [ArticleFetcher]
if let fetcher = exceptionArticleFetcher {
fetchers.append(fetcher)
exceptionArticleFetcher = nil
}
fetchUnsortedArticlesAsync(for: fetchers) { [weak self] (articles) in
self?.replaceArticles(with: articles)
}
}
func cancelPendingAsyncFetches() {
fetchSerialNumber += 1
fetchRequestQueue.cancelAllRequests()
}
func fetchUnsortedArticlesAsync(for representedObjects: [Any], completion: @escaping ArticleSetBlock) {
// The callback will *not* be called if the fetch is no longer relevant that is,
// if its been superseded by a newer fetch, or the timeline was emptied, etc., it wont get called.
precondition(Thread.isMainThread)
cancelPendingAsyncFetches()
let fetchOperation = FetchRequestOperation(id: fetchSerialNumber, readFilter: isReadFiltered ?? true, representedObjects: representedObjects) { [weak self] (articles, operation) in
precondition(Thread.isMainThread)
guard !operation.isCanceled, let strongSelf = self, operation.id == strongSelf.fetchSerialNumber else {
return
}
completion(articles)
}
fetchRequestQueue.add(fetchOperation)
}
func replaceArticles(with unsortedArticles: Set<Article>) {
articles = Array(unsortedArticles).sortedByDate(sortDirection, groupByFeed: groupByFeed)
timelineItems = articles.map { TimelineItem(article: $0) }
// TODO: Update unread counts and other item done in didSet on AppKit
}
// MARK: - Notifications
}

View File

@@ -9,13 +9,23 @@
import SwiftUI
struct TimelineView: View {
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
}
}
@EnvironmentObject private var timelineModel: TimelineModel
struct TimelineView_Previews: PreviewProvider {
static var previews: some View {
TimelineView()
var body: some View {
ScrollView {
LazyVStack() {
ForEach(timelineModel.timelineItems) { timelineItem in
TimelineItemView(timelineItem: timelineItem)
}
}
}
}
// var body: some View {
// List(timelineModel.timelineItems) { timelineItem in
// TimelineItemView(timelineItem: timelineItem)
// }
// }
}