From c2a8b59e368198f5a24cb8626d3d38af151ae5d4 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Tue, 3 Sep 2019 21:58:07 -0700 Subject: [PATCH] Adds News tab --- AltStore.xcodeproj/project.pbxproj | 34 +- AltStore/Base.lproj/Main.storyboard | 54 ++- AltStore/Components/AppBannerView.swift | 40 ++ AltStore/Components/AppBannerView.xib | 87 ++++ .../AltStore.xcdatamodel/contents | 26 +- AltStore/Model/MergePolicy.swift | 10 + AltStore/Model/NewsItem.swift | 90 ++++ AltStore/Model/Source.swift | 32 ++ AltStore/News/NewsCollectionViewCell.swift | 27 ++ AltStore/News/NewsCollectionViewCell.xib | 77 ++++ AltStore/News/NewsViewController.swift | 397 ++++++++++++++++++ AltStore/Resources/Apps-Staging.json | 41 ++ 12 files changed, 908 insertions(+), 7 deletions(-) create mode 100644 AltStore/Components/AppBannerView.swift create mode 100644 AltStore/Components/AppBannerView.xib create mode 100644 AltStore/Model/NewsItem.swift create mode 100644 AltStore/News/NewsCollectionViewCell.swift create mode 100644 AltStore/News/NewsCollectionViewCell.xib create mode 100644 AltStore/News/NewsViewController.swift diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 61c04421..e041a556 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -26,6 +26,8 @@ BF1E315F22A0635900370A3C /* libAltKit.a in Frameworks */ = {isa = PBXBuildFile; fileRef = BF1E315022A0616100370A3C /* libAltKit.a */; }; BF1E316022A0636400370A3C /* libAltKit.a in Frameworks */ = {isa = PBXBuildFile; fileRef = BF1E315022A0616100370A3C /* libAltKit.a */; }; BF258CE322EBAE2800023032 /* AppProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF258CE222EBAE2800023032 /* AppProtocol.swift */; }; + BF29012F2318F6B100D88A45 /* AppBannerView.xib in Resources */ = {isa = PBXBuildFile; fileRef = BF29012E2318F6B100D88A45 /* AppBannerView.xib */; }; + BF2901312318F7A800D88A45 /* AppBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF2901302318F7A800D88A45 /* AppBannerView.swift */; }; BF3D648822E79A3700E9056B /* AppPermission.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF3D648722E79A3700E9056B /* AppPermission.swift */; }; BF3D648D22E79AC800E9056B /* ALTAppPermission.m in Sources */ = {isa = PBXBuildFile; fileRef = BF3D648C22E79AC800E9056B /* ALTAppPermission.m */; }; BF3D649D22E7AC1B00E9056B /* PermissionPopoverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF3D649C22E7AC1B00E9056B /* PermissionPopoverViewController.swift */; }; @@ -125,6 +127,10 @@ BFB1169B2293274D00BB457C /* JSONDecoder+ManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFB1169A2293274D00BB457C /* JSONDecoder+ManagedObjectContext.swift */; }; BFB1169D22932DB100BB457C /* Apps-Staging.json in Resources */ = {isa = PBXBuildFile; fileRef = BFB1169C22932DB100BB457C /* Apps-Staging.json */; }; BFB4323F22DE852000B7F8BC /* UpdateCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = BFB4323E22DE852000B7F8BC /* UpdateCollectionViewCell.xib */; }; + BFB6B21B23186D640022A802 /* NewsItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFB6B21A23186D640022A802 /* NewsItem.swift */; }; + BFB6B21E231870160022A802 /* NewsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFB6B21D231870160022A802 /* NewsViewController.swift */; }; + BFB6B220231870B00022A802 /* NewsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFB6B21F231870B00022A802 /* NewsCollectionViewCell.swift */; }; + BFB6B22423187A3D0022A802 /* NewsCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = BFB6B22323187A3D0022A802 /* NewsCollectionViewCell.xib */; }; BFBBE2DD22931B20002097FA /* AltStore.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = BFBBE2DB22931B20002097FA /* AltStore.xcdatamodeld */; }; BFBBE2DF22931F73002097FA /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFBBE2DE22931F73002097FA /* App.swift */; }; BFBBE2E122931F81002097FA /* InstalledApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFBBE2E022931F81002097FA /* InstalledApp.swift */; }; @@ -279,6 +285,8 @@ BF1E315022A0616100370A3C /* libAltKit.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libAltKit.a; sourceTree = BUILT_PRODUCTS_DIR; }; BF219A7E22CAC431007676A6 /* AltStore.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AltStore.entitlements; sourceTree = ""; }; BF258CE222EBAE2800023032 /* AppProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppProtocol.swift; sourceTree = ""; }; + BF29012E2318F6B100D88A45 /* AppBannerView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AppBannerView.xib; sourceTree = ""; }; + BF2901302318F7A800D88A45 /* AppBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppBannerView.swift; sourceTree = ""; }; BF3D648722E79A3700E9056B /* AppPermission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPermission.swift; sourceTree = ""; }; BF3D648B22E79AC800E9056B /* ALTAppPermission.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ALTAppPermission.h; sourceTree = ""; }; BF3D648C22E79AC800E9056B /* ALTAppPermission.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ALTAppPermission.m; sourceTree = ""; }; @@ -386,6 +394,10 @@ BFB1169A2293274D00BB457C /* JSONDecoder+ManagedObjectContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "JSONDecoder+ManagedObjectContext.swift"; sourceTree = ""; }; BFB1169C22932DB100BB457C /* Apps-Staging.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "Apps-Staging.json"; sourceTree = ""; }; BFB4323E22DE852000B7F8BC /* UpdateCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = UpdateCollectionViewCell.xib; sourceTree = ""; }; + BFB6B21A23186D640022A802 /* NewsItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsItem.swift; sourceTree = ""; }; + BFB6B21D231870160022A802 /* NewsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsViewController.swift; sourceTree = ""; }; + BFB6B21F231870B00022A802 /* NewsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsCollectionViewCell.swift; sourceTree = ""; }; + BFB6B22323187A3D0022A802 /* NewsCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = NewsCollectionViewCell.xib; sourceTree = ""; }; BFBAC8852295C90300587369 /* Result+Conveniences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Result+Conveniences.swift"; sourceTree = ""; }; BFBBE2DC22931B20002097FA /* AltStore.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = AltStore.xcdatamodel; sourceTree = ""; }; BFBBE2DE22931F73002097FA /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; @@ -740,6 +752,16 @@ path = Browse; sourceTree = ""; }; + BFB6B21C2318700D0022A802 /* News */ = { + isa = PBXGroup; + children = ( + BFB6B21D231870160022A802 /* NewsViewController.swift */, + BFB6B21F231870B00022A802 /* NewsCollectionViewCell.swift */, + BFB6B22323187A3D0022A802 /* NewsCollectionViewCell.xib */, + ); + path = News; + sourceTree = ""; + }; BFBBE2E2229320A2002097FA /* My Apps */ = { isa = PBXGroup; children = ( @@ -789,9 +811,10 @@ children = ( BF219A7E22CAC431007676A6 /* AltStore.entitlements */, BFD2476D2284B9A500981D42 /* AppDelegate.swift */, - BFE338E722F10E56002E24B9 /* LaunchViewController.swift */, BFD247732284B9A500981D42 /* Main.storyboard */, + BFE338E722F10E56002E24B9 /* LaunchViewController.swift */, BFE6325822A83BA800F30809 /* Authentication */, + BFB6B21C2318700D0022A802 /* News */, BF9ABA4322DCFF33008935CF /* Browse */, BF3D64A022E7FAD800E9056B /* App Detail */, BFBBE2E2229320A2002097FA /* My Apps */, @@ -845,6 +868,8 @@ BF9ABA4C22DD16DE008935CF /* PillButton.swift */, BF18B0F022E25DF9005C4CF5 /* ToastView.swift */, BF3D649E22E7B24C00E9056B /* CollapsingTextView.swift */, + BF2901302318F7A800D88A45 /* AppBannerView.swift */, + BF29012E2318F6B100D88A45 /* AppBannerView.xib */, ); path = Components; sourceTree = ""; @@ -879,6 +904,7 @@ BFBBE2DE22931F73002097FA /* App.swift */, BF3D648722E79A3700E9056B /* AppPermission.swift */, BFBBE2E022931F81002097FA /* InstalledApp.swift */, + BFB6B21A23186D640022A802 /* NewsItem.swift */, BFD5D6E9230CCAE5007955AB /* PatreonAccount.swift */, BF02419322F2156E00129732 /* RefreshAttempt.swift */, BFE338DC22F0E7F3002E24B9 /* Source.swift */, @@ -1175,8 +1201,10 @@ BFD2477A2284B9A700981D42 /* LaunchScreen.storyboard in Resources */, BF770E6922BD57DD002A40FE /* Silence.m4a in Resources */, BFD247772284B9A700981D42 /* Assets.xcassets in Resources */, + BFB6B22423187A3D0022A802 /* NewsCollectionViewCell.xib in Resources */, BFD247752284B9A500981D42 /* Main.storyboard in Resources */, BFDB5B2622EFBBEA00F74113 /* BrowseCollectionViewCell.xib in Resources */, + BF29012F2318F6B100D88A45 /* AppBannerView.xib in Resources */, BFE6325A22A83BEB00F30809 /* Authentication.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1336,7 +1364,9 @@ BFBBE2E122931F81002097FA /* InstalledApp.swift in Sources */, BFE338DF22F0EADB002E24B9 /* FetchSourceOperation.swift in Sources */, BFBBE2DF22931F73002097FA /* App.swift in Sources */, + BFB6B21E231870160022A802 /* NewsViewController.swift in Sources */, BFD5D6F4230DDB0A007955AB /* Campaign.swift in Sources */, + BFB6B21B23186D640022A802 /* NewsItem.swift in Sources */, BFD5D6E8230CC961007955AB /* PatreonAPI.swift in Sources */, BF9ABA4522DCFF43008935CF /* BrowseViewController.swift in Sources */, BF43002E22A714AF0051E2BC /* Keychain.swift in Sources */, @@ -1375,11 +1405,13 @@ BF770E5122BB1CF6002A40FE /* InstallAppOperation.swift in Sources */, BF9ABA4B22DD1380008935CF /* NavigationBar.swift in Sources */, BF0C4EBD22A1BD8B009A2DD7 /* AppManager.swift in Sources */, + BF2901312318F7A800D88A45 /* AppBannerView.swift in Sources */, BF3D648D22E79AC800E9056B /* ALTAppPermission.m in Sources */, BFD5D6F2230DD974007955AB /* Benefit.swift in Sources */, BF9ABA4922DD0742008935CF /* ScreenshotCollectionViewCell.swift in Sources */, BF9ABA4D22DD16DE008935CF /* PillButton.swift in Sources */, BFE6326C22A86FF300F30809 /* AuthenticationOperation.swift in Sources */, + BFB6B220231870B00022A802 /* NewsCollectionViewCell.swift in Sources */, BFDB6A0522A9AFB2007EA6D6 /* Fetchable.swift in Sources */, BFD2479F2284FBD000981D42 /* UIColor+AltStore.swift in Sources */, BFDB5B1622EE90D300F74113 /* Date+RelativeDate.swift in Sources */, diff --git a/AltStore/Base.lproj/Main.storyboard b/AltStore/Base.lproj/Main.storyboard index 224070bd..14bfc1ab 100644 --- a/AltStore/Base.lproj/Main.storyboard +++ b/AltStore/Base.lproj/Main.storyboard @@ -39,9 +39,10 @@ - - - + + + + @@ -594,7 +595,7 @@ World - + @@ -908,6 +909,32 @@ World + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1108,6 +1135,25 @@ World + + + + + + + + + + + + + + + + + + + diff --git a/AltStore/Components/AppBannerView.swift b/AltStore/Components/AppBannerView.swift new file mode 100644 index 00000000..3187fbd9 --- /dev/null +++ b/AltStore/Components/AppBannerView.swift @@ -0,0 +1,40 @@ +// +// AppBannerView.swift +// AltStore +// +// Created by Riley Testut on 8/29/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import UIKit +import Roxas + +class AppBannerView: RSTNibView +{ + @IBOutlet var titleLabel: UILabel! + @IBOutlet var subtitleLabel: UILabel! + @IBOutlet var iconImageView: AppIconImageView! + @IBOutlet var button: PillButton! + @IBOutlet var betaBadgeView: UIView! + + override func tintColorDidChange() + { + super.tintColorDidChange() + + self.update() + } +} + +private extension AppBannerView +{ + func update() + { + self.clipsToBounds = true + self.layer.cornerRadius = 22 + + self.subtitleLabel.textColor = self.tintColor + self.button.tintColor = self.tintColor + + self.backgroundColor = self.tintColor.withAlphaComponent(0.1) + } +} diff --git a/AltStore/Components/AppBannerView.xib b/AltStore/Components/AppBannerView.xib new file mode 100644 index 00000000..13e7100b --- /dev/null +++ b/AltStore/Components/AppBannerView.xib @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AltStore/Model/AltStore.xcdatamodeld/AltStore.xcdatamodel/contents b/AltStore/Model/AltStore.xcdatamodeld/AltStore.xcdatamodel/contents index 67e47dda..33ee1c94 100644 --- a/AltStore/Model/AltStore.xcdatamodeld/AltStore.xcdatamodel/contents +++ b/AltStore/Model/AltStore.xcdatamodeld/AltStore.xcdatamodel/contents @@ -32,6 +32,25 @@ + + + + + + + + + + + + + + + + + + + @@ -59,6 +78,7 @@ + @@ -82,6 +102,7 @@ + @@ -106,10 +127,11 @@ + - - + + \ No newline at end of file diff --git a/AltStore/Model/MergePolicy.swift b/AltStore/Model/MergePolicy.swift index 4f0491df..0a5faa13 100644 --- a/AltStore/Model/MergePolicy.swift +++ b/AltStore/Model/MergePolicy.swift @@ -34,6 +34,7 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy guard let conflictedObject = conflict.conflictingObjects.first as? Source else { break } let bundleIdentifiers = Set(conflictedObject.apps.map { $0.bundleIdentifier }) + let newsItemIdentifiers = Set(conflictedObject.newsItems.map { $0.identifier }) for app in databaseObject.apps { @@ -44,6 +45,15 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy } } + for newsItem in databaseObject.newsItems + { + if !newsItemIdentifiers.contains(newsItem.identifier) + { + // No longer listed in Source, so remove it from database. + newsItem.managedObjectContext?.delete(newsItem) + } + } + default: break } } diff --git a/AltStore/Model/NewsItem.swift b/AltStore/Model/NewsItem.swift new file mode 100644 index 00000000..84efd54a --- /dev/null +++ b/AltStore/Model/NewsItem.swift @@ -0,0 +1,90 @@ +// +// NewsItem.swift +// AltStore +// +// Created by Riley Testut on 8/29/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import UIKit +import CoreData + +@objc(NewsItem) +class NewsItem: NSManagedObject, Decodable, Fetchable +{ + /* Properties */ + @NSManaged var identifier: String + @NSManaged var date: Date + + @NSManaged var title: String + @NSManaged var caption: String + @NSManaged var tintColor: UIColor + @NSManaged var sortIndex: Int32 + @NSManaged var isSilent: Bool + + @NSManaged var imageURL: URL? + @NSManaged var externalURL: URL? + + @NSManaged var appID: String? + + /* Relationships */ + @NSManaged var storeApp: StoreApp? + @NSManaged var source: Source? + + private enum CodingKeys: String, CodingKey + { + case identifier + case date + case title + case caption + case tintColor + case imageURL + case externalURL = "url" + case appID + case notify + } + + private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?) + { + super.init(entity: entity, insertInto: context) + } + + required init(from decoder: Decoder) throws + { + guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") } + + super.init(entity: NewsItem.entity(), insertInto: context) + + let container = try decoder.container(keyedBy: CodingKeys.self) + self.identifier = try container.decode(String.self, forKey: .identifier) + self.date = try container.decode(Date.self, forKey: .date) + + self.title = try container.decode(String.self, forKey: .title) + self.caption = try container.decode(String.self, forKey: .caption) + + if let tintColorHex = try container.decodeIfPresent(String.self, forKey: .tintColor) + { + guard let tintColor = UIColor(hexString: tintColorHex) else { + throw DecodingError.dataCorruptedError(forKey: .tintColor, in: container, debugDescription: "Hex code is invalid.") + } + + self.tintColor = tintColor + } + + self.imageURL = try container.decodeIfPresent(URL.self, forKey: .imageURL) + self.externalURL = try container.decodeIfPresent(URL.self, forKey: .externalURL) + + self.appID = try container.decodeIfPresent(String.self, forKey: .appID) + + let notify = try container.decodeIfPresent(Bool.self, forKey: .notify) ?? false + self.isSilent = !notify + } +} + +extension NewsItem +{ + @nonobjc class func fetchRequest() -> NSFetchRequest + { + return NSFetchRequest(entityName: "NewsItem") + } +} diff --git a/AltStore/Model/Source.swift b/AltStore/Model/Source.swift index 7aa086c7..aca0f70d 100644 --- a/AltStore/Model/Source.swift +++ b/AltStore/Model/Source.swift @@ -23,6 +23,7 @@ class Source: NSManagedObject, Fetchable, Decodable /* Relationships */ @objc(apps) @NSManaged private(set) var _apps: NSOrderedSet + @objc(newsItems) @NSManaged private(set) var _newsItems: NSOrderedSet @nonobjc var apps: [StoreApp] { get { @@ -33,12 +34,22 @@ class Source: NSManagedObject, Fetchable, Decodable } } + @nonobjc var newsItems: [NewsItem] { + get { + return self._newsItems.array as! [NewsItem] + } + set { + self._newsItems = NSOrderedSet(array: newValue) + } + } + private enum CodingKeys: String, CodingKey { case name case identifier case sourceURL case apps + case news } private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?) @@ -63,10 +74,31 @@ class Source: NSManagedObject, Fetchable, Decodable app.sortIndex = Int32(index) } + let newsItems = try container.decodeIfPresent([NewsItem].self, forKey: .news) ?? [] + for (index, item) in newsItems.enumerated() + { + item.sortIndex = Int32(index) + } + context.insert(self) + let appsByID = Dictionary(apps.map { ($0.bundleIdentifier, $0) }, uniquingKeysWith: { (a, b) in return a }) + + for newsItem in newsItems + { + newsItem.source = self + + guard let appID = newsItem.appID else { continue } + + if let storeApp = appsByID[appID] + { + newsItem.storeApp = storeApp + } + } + // Must assign after we're inserted into context. self._apps = NSMutableOrderedSet(array: apps) + self._newsItems = NSMutableOrderedSet(array: newsItems) print("Downloaded Order:", self.apps.map { $0.bundleIdentifier }) } diff --git a/AltStore/News/NewsCollectionViewCell.swift b/AltStore/News/NewsCollectionViewCell.swift new file mode 100644 index 00000000..adf9ba18 --- /dev/null +++ b/AltStore/News/NewsCollectionViewCell.swift @@ -0,0 +1,27 @@ +// +// NewsCollectionViewCell.swift +// AltStore +// +// Created by Riley Testut on 8/29/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import UIKit + +class NewsCollectionViewCell: UICollectionViewCell +{ + @IBOutlet var titleLabel: UILabel! + @IBOutlet var captionLabel: UILabel! + @IBOutlet var imageView: UIImageView! + + override func awakeFromNib() + { + super.awakeFromNib() + + self.contentView.layer.cornerRadius = 30 + self.contentView.clipsToBounds = true + + self.imageView.layer.cornerRadius = 30 + self.imageView.clipsToBounds = true + } +} diff --git a/AltStore/News/NewsCollectionViewCell.xib b/AltStore/News/NewsCollectionViewCell.xib new file mode 100644 index 00000000..0b29532b --- /dev/null +++ b/AltStore/News/NewsCollectionViewCell.xib @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AltStore/News/NewsViewController.swift b/AltStore/News/NewsViewController.swift new file mode 100644 index 00000000..32bf2ef7 --- /dev/null +++ b/AltStore/News/NewsViewController.swift @@ -0,0 +1,397 @@ +// +// NewsViewController.swift +// AltStore +// +// Created by Riley Testut on 8/29/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import UIKit +import SafariServices + +import Roxas + +import Nuke + +private class AppBannerFooterView: UICollectionReusableView +{ + let bannerView = AppBannerView(frame: .zero) + let tapGestureRecognizer = UITapGestureRecognizer(target: nil, action: nil) + + override init(frame: CGRect) + { + super.init(frame: frame) + + self.addSubview(self.bannerView, pinningEdgesWith: UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20)) + self.addGestureRecognizer(self.tapGestureRecognizer) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +class NewsViewController: UICollectionViewController +{ + private lazy var dataSource = self.makeDataSource() + private var prototypeCell: NewsCollectionViewCell! + + // Cache + private var cachedCellSizes = [String: CGSize]() + + override func viewDidLoad() + { + super.viewDidLoad() + + self.prototypeCell = NewsCollectionViewCell.instantiate(with: NewsCollectionViewCell.nib!) + self.prototypeCell.translatesAutoresizingMaskIntoConstraints = false + self.prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false + + self.collectionView.contentInset.bottom = 20 + + self.collectionView.dataSource = self.dataSource + self.collectionView.prefetchDataSource = self.dataSource + + self.collectionView.register(NewsCollectionViewCell.nib, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier) + self.collectionView.register(AppBannerFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: "AppBanner") + + self.registerForPreviewing(with: self, sourceView: self.collectionView) + } + + override func viewWillAppear(_ animated: Bool) + { + super.viewWillAppear(animated) + + self.fetchSource() + } +} + +private extension NewsViewController +{ + func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource + { + let fetchRequest = NewsItem.fetchRequest() as NSFetchRequest + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \NewsItem.sortIndex, ascending: false)] + + let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(NewsItem.date), cacheName: nil) + + let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource(fetchedResultsController: fetchedResultsController) + dataSource.proxy = self + dataSource.cellConfigurationHandler = { (cell, newsItem, indexPath) in + let cell = cell as! NewsCollectionViewCell + cell.titleLabel.text = newsItem.title + cell.captionLabel.text = newsItem.caption + cell.contentView.backgroundColor = newsItem.tintColor + + cell.imageView.image = nil + + if newsItem.imageURL != nil + { + cell.imageView.isIndicatingActivity = true + cell.imageView.isHidden = false + } + else + { + cell.imageView.isIndicatingActivity = false + cell.imageView.isHidden = true + } + } + dataSource.prefetchHandler = { (newsItem, indexPath, completionHandler) in + guard let imageURL = newsItem.imageURL else { return nil } + + return RSTAsyncBlockOperation() { (operation) in + ImagePipeline.shared.loadImage(with: imageURL, progress: nil, completion: { (response, error) in + guard !operation.isCancelled else { return operation.finish() } + + if let image = response?.image + { + completionHandler(image, nil) + } + else + { + completionHandler(nil, error) + } + }) + } + } + dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in + let cell = cell as! NewsCollectionViewCell + cell.imageView.isIndicatingActivity = false + cell.imageView.image = image + + if let error = error + { + print("Error loading image:", error) + } + } + + return dataSource + } + + func fetchSource() + { + AppManager.shared.fetchSource() { (result) in + do + { + let source = try result.get() + try source.managedObjectContext?.save() + } + catch + { + DispatchQueue.main.async { + let toastView = ToastView(text: error.localizedDescription, detailText: nil) + toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0) + } + } + } + } +} + +private extension NewsViewController +{ + @objc func handleTapGesture(_ gestureRecognizer: UITapGestureRecognizer) + { + guard let footerView = gestureRecognizer.view as? UICollectionReusableView else { return } + + let indexPaths = self.collectionView.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter) + + guard let indexPath = indexPaths.first(where: { (indexPath) -> Bool in + let supplementaryView = self.collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: indexPath) + return supplementaryView == footerView + }) else { return } + + let item = self.dataSource.item(at: indexPath) + guard let storeApp = item.storeApp else { return } + + let appViewController = AppViewController.makeAppViewController(app: storeApp) + self.navigationController?.pushViewController(appViewController, animated: true) + } + + @objc func performAppAction(_ sender: PillButton) + { + let point = self.collectionView.convert(sender.center, from: sender.superview) + let indexPaths = self.collectionView.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter) + + guard let indexPath = indexPaths.first(where: { (indexPath) -> Bool in + let supplementaryView = self.collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: indexPath) + return supplementaryView?.frame.contains(point) ?? false + }) else { return } + + let app = self.dataSource.item(at: indexPath) + guard let storeApp = app.storeApp else { return } + + if let installedApp = app.storeApp?.installedApp + { + self.open(installedApp) + } + else + { + self.install(storeApp, at: indexPath) + } + } + + @objc func install(_ storeApp: StoreApp, at indexPath: IndexPath) + { + let previousProgress = AppManager.shared.installationProgress(for: storeApp) + guard previousProgress == nil else { + previousProgress?.cancel() + return + } + + _ = AppManager.shared.install(storeApp, presentingViewController: self) { (result) in + DispatchQueue.main.async { + switch result + { + case .failure(OperationError.cancelled): break // Ignore + case .failure(let error): + let toastView = ToastView(text: error.localizedDescription, detailText: nil) + toastView.show(in: self.navigationController?.view ?? self.view, duration: 2) + + case .success: print("Installed app:", storeApp.bundleIdentifier) + } + + UIView.performWithoutAnimation { + self.collectionView.reloadSections(IndexSet(integer: indexPath.section)) + } + } + } + + UIView.performWithoutAnimation { + self.collectionView.reloadSections(IndexSet(integer: indexPath.section)) + } + } + + func open(_ installedApp: InstalledApp) + { + UIApplication.shared.open(installedApp.openAppURL) + } +} + +extension NewsViewController +{ + override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) + { + let newsItem = self.dataSource.item(at: indexPath) + + if let externalURL = newsItem.externalURL + { + let safariViewController = SFSafariViewController(url: externalURL) + safariViewController.preferredControlTintColor = newsItem.tintColor + self.present(safariViewController, animated: true, completion: nil) + } + else if let storeApp = newsItem.storeApp + { + let appViewController = AppViewController.makeAppViewController(app: storeApp) + self.navigationController?.pushViewController(appViewController, animated: true) + } + } + + override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView + { + let item = self.dataSource.item(at: indexPath) + + let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: "AppBanner", for: indexPath) as! AppBannerFooterView + guard let storeApp = item.storeApp else { return footerView } + + footerView.bannerView.titleLabel.text = storeApp.name + footerView.bannerView.subtitleLabel.text = storeApp.developerName + footerView.bannerView.tintColor = storeApp.tintColor + footerView.bannerView.betaBadgeView.isHidden = !storeApp.isBeta + footerView.bannerView.button.addTarget(self, action: #selector(NewsViewController.performAppAction(_:)), for: .primaryActionTriggered) + footerView.tapGestureRecognizer.addTarget(self, action: #selector(NewsViewController.handleTapGesture(_:))) + + footerView.bannerView.button.isIndicatingActivity = false + + if storeApp.installedApp == nil + { + footerView.bannerView.button.setTitle(NSLocalizedString("FREE", comment: ""), for: .normal) + + let progress = AppManager.shared.installationProgress(for: storeApp) + footerView.bannerView.button.progress = progress + footerView.bannerView.button.isInverted = false + } + else + { + footerView.bannerView.button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal) + footerView.bannerView.button.progress = nil + footerView.bannerView.button.isInverted = true + } + + Nuke.loadImage(with: storeApp.iconURL, into: footerView.bannerView.iconImageView) + + return footerView + } +} + +extension NewsViewController: UICollectionViewDelegateFlowLayout +{ + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize + { + let padding = 40 as CGFloat + let width = collectionView.bounds.width - padding + + let item = self.dataSource.item(at: indexPath) + + if let previousSize = self.cachedCellSizes[item.identifier] + { + return previousSize + } + + let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: width) + NSLayoutConstraint.activate([widthConstraint]) + defer { NSLayoutConstraint.deactivate([widthConstraint]) } + + self.dataSource.cellConfigurationHandler(self.prototypeCell, item, indexPath) + + let size = self.prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + self.cachedCellSizes[item.identifier] = size + return size + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize + { + let item = self.dataSource.item(at: IndexPath(row: 0, section: section)) + + if item.storeApp != nil + { + return CGSize(width: 88, height: 88) + } + else + { + return .zero + } + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets + { + var insets = UIEdgeInsets(top: 30, left: 20, bottom: 13, right: 20) + + if section == 0 + { + insets.top = 10 + } + + return insets + } +} + +extension NewsViewController: UIViewControllerPreviewingDelegate +{ + func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? + { + if let indexPath = self.collectionView.indexPathForItem(at: location), let cell = self.collectionView.cellForItem(at: indexPath) + { + // Previewing news item. + + previewingContext.sourceRect = cell.frame + + let newsItem = self.dataSource.item(at: indexPath) + + if let externalURL = newsItem.externalURL + { + let safariViewController = SFSafariViewController(url: externalURL) + safariViewController.preferredControlTintColor = newsItem.tintColor + return safariViewController + } + else if let storeApp = newsItem.storeApp + { + let appViewController = AppViewController.makeAppViewController(app: storeApp) + return appViewController + } + + return nil + } + else + { + // Previewing app banner (or nothing). + + let indexPaths = self.collectionView.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter) + + guard let indexPath = indexPaths.first(where: { (indexPath) -> Bool in + let layoutAttributes = self.collectionView.layoutAttributesForSupplementaryElement(ofKind: UICollectionView.elementKindSectionFooter, at: indexPath) + return layoutAttributes?.frame.contains(location) ?? false + }) else { return nil } + + guard let layoutAttributes = self.collectionView.layoutAttributesForSupplementaryElement(ofKind: UICollectionView.elementKindSectionFooter, at: indexPath) else { return nil } + previewingContext.sourceRect = layoutAttributes.frame + + let item = self.dataSource.item(at: indexPath) + guard let storeApp = item.storeApp else { return nil } + + let appViewController = AppViewController.makeAppViewController(app: storeApp) + return appViewController + } + } + + func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController) + { + if let safariViewController = viewControllerToCommit as? SFSafariViewController + { + self.present(safariViewController, animated: true, completion: nil) + } + else + { + self.navigationController?.pushViewController(viewControllerToCommit, animated: true) + } + } +} diff --git a/AltStore/Resources/Apps-Staging.json b/AltStore/Resources/Apps-Staging.json index 63b62833..8233ee12 100644 --- a/AltStore/Resources/Apps-Staging.json +++ b/AltStore/Resources/Apps-Staging.json @@ -97,5 +97,46 @@ "https://user-images.githubusercontent.com/705880/63391950-34286600-c37a-11e9-965f-832efe3da507.png" ] } + ], + "news": [ + { + "title": "Welcome to AltStore", + "identifier": "welcometoaltstore", + "caption": "Check out the FAQ for more information on how to install apps.", + "tintColor": "397E65", + "url": "http://rileytestut.com", + "appID": "com.rileytestut.AltStore", + "date": "2019-08-27" + } + , + { + "title": "Why Clip?", + "identifier": "whyclip", + "caption": "Clip lets you track your clipboard history even when in the background, something App Store apps can't do.", + "tintColor": "EC008C", + "url": "http://rileytestut.com", + "imageURL": "https://user-images.githubusercontent.com/705880/63391948-32f73900-c37a-11e9-976c-275bd8d557f0.png", + "appID": "com.rileytestut.Clip", + "date": "2019-08-28" + }, + { + "title": "Delta Now Available", + "identifier": "deltaavailable", + "caption": "After almost 5 years in development, Delta is finally finished.", + "tintColor": "8A28F7", + "imageURL": "https://boygeniusreport.files.wordpress.com/2017/05/delta-emulator-game-boy-color.jpg?quality=98&strip=all&w=782", + "appID": "com.rileytestut.Delta", + "date": "2019-08-29" + }, + { + "title": "Delta Gaining DS Support", + "identifier": "deltadspreview", + "caption": "Check out the upcoming DS support before everyone else when you become a Patron.", + "tintColor": "8A28F7", + "imageURL": "https://boygeniusreport.files.wordpress.com/2017/05/delta-emulator-game-boy-color.jpg?quality=98&strip=all&w=782", + "appID": "com.rileytestut.Delta", + "date": "2019-08-30", + "notify": true + } ] }