From b03b7bfe68626063115e58c0714e6b72e48a8df3 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Tue, 17 Oct 2023 14:49:13 -0500 Subject: [PATCH] Refactors SourceViewController into dedicated tab * Updates UI to use source icons + tint colors * Adds Edit button + swipe actions --- AltStore.xcodeproj/project.pbxproj | 4 + AltStore/Base.lproj/Main.storyboard | 25 +- AltStore/Browse/BrowseViewController.swift | 5 - .../AppBannerCollectionViewCell.swift | 41 +- AltStore/Components/AppBannerView.swift | 26 + .../Extensions/UIFontDescriptor+Bold.swift | 18 + AltStore/News/NewsViewController.swift | 5 - .../Assets.xcassets/Tabs/Contents.json | 6 +- .../Combined Shape.png | Bin .../Combined Shape@2x.png | Bin .../Combined Shape@3x.png | Bin .../Contents.json | 0 AltStore/Sources/Sources.storyboard | 173 ++--- AltStore/Sources/SourcesViewController.swift | 696 +++++++++--------- AltStore/TabBarController.swift | 47 +- AltStoreCore/Model/Source.swift | 17 + 16 files changed, 517 insertions(+), 546 deletions(-) create mode 100644 AltStore/Extensions/UIFontDescriptor+Bold.swift rename AltStore/Resources/Assets.xcassets/Tabs/{Browse.imageset => Sources.imageset}/Combined Shape.png (100%) rename AltStore/Resources/Assets.xcassets/Tabs/{Browse.imageset => Sources.imageset}/Combined Shape@2x.png (100%) rename AltStore/Resources/Assets.xcassets/Tabs/{Browse.imageset => Sources.imageset}/Combined Shape@3x.png (100%) rename AltStore/Resources/Assets.xcassets/Tabs/{Browse.imageset => Sources.imageset}/Contents.json (100%) diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index ecd34ec9..386a19b0 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -433,6 +433,7 @@ D5F5AF7D28ECEA990067C736 /* ErrorDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F5AF7C28ECEA990067C736 /* ErrorDetailsViewController.swift */; }; D5F99A1828D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = D5F99A1728D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel */; }; D5F99A1A28D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F99A1928D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift */; }; + D5FB28EC2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */; }; D5FB28EE2ADDF89800A1C337 /* KnownSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5893F812A141E4900E767CD /* KnownSource.swift */; }; D5FB7A0E2AA25A4E00EF863D /* Previews.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D5FB7A0D2AA25A4E00EF863D /* Previews.xcassets */; }; D5FB7A212AA284ED00EF863D /* EnableJIT.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5FB7A1A2AA284ED00EF863D /* EnableJIT.swift */; }; @@ -1089,6 +1090,7 @@ D5F5AF7C28ECEA990067C736 /* ErrorDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorDetailsViewController.swift; sourceTree = ""; }; D5F99A1728D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = AltStore10ToAltStore11.xcmappingmodel; sourceTree = ""; }; D5F99A1928D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreApp10ToStoreApp11Policy.swift; sourceTree = ""; }; + D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFontDescriptor+Bold.swift"; sourceTree = ""; }; D5FB7A0D2AA25A4E00EF863D /* Previews.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Previews.xcassets; sourceTree = ""; }; D5FB7A132AA284BE00EF863D /* altjit */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = altjit; sourceTree = BUILT_PRODUCTS_DIR; }; D5FB7A1A2AA284ED00EF863D /* EnableJIT.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = EnableJIT.swift; path = AltJIT/Commands/EnableJIT.swift; sourceTree = SOURCE_ROOT; }; @@ -2007,6 +2009,7 @@ 0E13E5852CC8F55900E9C0DF /* ProcessInfo+SideStore.swift */, D5927D6529DCC89000D6898E /* UINavigationBarAppearance+TintColor.swift */, D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */, + D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */, ); path = Extensions; sourceTree = ""; @@ -3151,6 +3154,7 @@ BFB6B21E231870160022A802 /* NewsViewController.swift in Sources */, BFC57A652416C72400EB891E /* DeactivateAppOperation.swift in Sources */, D5CD805D29CA2C1E00E591B0 /* HeaderContentViewController.swift in Sources */, + D5FB28EC2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift in Sources */, BF3BEFC124086A1E00DE7D55 /* RefreshAppOperation.swift in Sources */, BFE60740231AFD2A002B0E8E /* InsetGroupTableViewCell.swift in Sources */, BF0DCA662433BDF500E3A595 /* AnalyticsManager.swift in Sources */, diff --git a/AltStore/Base.lproj/Main.storyboard b/AltStore/Base.lproj/Main.storyboard index e411d1ff..64c0d7d1 100644 --- a/AltStore/Base.lproj/Main.storyboard +++ b/AltStore/Base.lproj/Main.storyboard @@ -36,11 +36,11 @@ - - - - + + + + @@ -67,16 +67,7 @@ - - - - - - - - - - + @@ -921,20 +912,20 @@ World - + + + - - diff --git a/AltStore/Browse/BrowseViewController.swift b/AltStore/Browse/BrowseViewController.swift index 87fa93d3..bb6a9d7b 100644 --- a/AltStore/Browse/BrowseViewController.swift +++ b/AltStore/Browse/BrowseViewController.swift @@ -92,11 +92,6 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing self.update() } - - @IBAction private func unwindFromSourcesViewController(_ segue: UIStoryboardSegue) - { - self.fetchSource() - } } private extension BrowseViewController diff --git a/AltStore/Components/AppBannerCollectionViewCell.swift b/AltStore/Components/AppBannerCollectionViewCell.swift index f304cba3..9e7813c0 100644 --- a/AltStore/Components/AppBannerCollectionViewCell.swift +++ b/AltStore/Components/AppBannerCollectionViewCell.swift @@ -8,12 +8,10 @@ import UIKit -final class BannerCollectionViewCell: UICollectionViewCell +class AppBannerCollectionViewCell: UICollectionViewListCell { let bannerView = AppBannerView(frame: .zero) - private(set) var errorBadge: UIView! - override init(frame: CGRect) { super.init(frame: frame) @@ -30,43 +28,24 @@ final class BannerCollectionViewCell: UICollectionViewCell private func initialize() { + // Prevent content "squishing" when scrolling offscreen. + self.insetsLayoutMarginsFromSafeArea = false + self.contentView.insetsLayoutMarginsFromSafeArea = false + self.bannerView.insetsLayoutMarginsFromSafeArea = false + + self.selectedBackgroundView = UIView() // Disable selection highlighting. + self.contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight] self.contentView.preservesSuperviewLayoutMargins = true self.bannerView.translatesAutoresizingMaskIntoConstraints = false self.contentView.addSubview(self.bannerView) - let errorBadge = UIView() - errorBadge.translatesAutoresizingMaskIntoConstraints = false - errorBadge.isHidden = true - self.addSubview(errorBadge) - - // Solid background to make the X opaque white. - let backgroundView = UIView() - backgroundView.translatesAutoresizingMaskIntoConstraints = false - backgroundView.backgroundColor = .white - errorBadge.addSubview(backgroundView) - - let badgeView = UIImageView(image: UIImage(systemName: "exclamationmark.circle.fill")) - badgeView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(scale: .large) - badgeView.tintColor = .systemRed - errorBadge.addSubview(badgeView, pinningEdgesWith: .zero) - NSLayoutConstraint.activate([ - self.bannerView.topAnchor.constraint(equalTo: self.contentView.topAnchor), - self.bannerView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor), + self.bannerView.topAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.topAnchor), + self.bannerView.bottomAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.bottomAnchor), self.bannerView.leadingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.leadingAnchor), self.bannerView.trailingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.trailingAnchor), - - errorBadge.centerXAnchor.constraint(equalTo: self.bannerView.trailingAnchor, constant: -5), - errorBadge.centerYAnchor.constraint(equalTo: self.bannerView.topAnchor, constant: 5), - - backgroundView.centerXAnchor.constraint(equalTo: badgeView.centerXAnchor), - backgroundView.centerYAnchor.constraint(equalTo: badgeView.centerYAnchor), - backgroundView.widthAnchor.constraint(equalTo: badgeView.widthAnchor, multiplier: 0.5), - backgroundView.heightAnchor.constraint(equalTo: badgeView.heightAnchor, multiplier: 0.5) ]) - - self.errorBadge = errorBadge } } diff --git a/AltStore/Components/AppBannerView.swift b/AltStore/Components/AppBannerView.swift index 86cb2b8b..7b0a8d8a 100644 --- a/AltStore/Components/AppBannerView.swift +++ b/AltStore/Components/AppBannerView.swift @@ -131,6 +131,8 @@ extension AppBannerView } } } + + self.style = .app let values = AppValues(app: app) self.titleLabel.text = app.name // Don't use values.name since that already includes "beta". @@ -147,6 +149,22 @@ extension AppBannerView self.accessibilityLabel = values.name } } + + func configure(for source: Source) + { + self.style = .source + + self.titleLabel.text = source.name + + let subtitle = source.subtitle ?? source.sourceURL.absoluteString + self.subtitleLabel.text = subtitle + + let tintColor = source.effectiveTintColor ?? .altPrimary + self.tintColor = tintColor + + let accessibilityLabel = source.name + "\n" + subtitle + self.accessibilityLabel = accessibilityLabel + } } private extension AppBannerView @@ -162,20 +180,28 @@ private extension AppBannerView switch self.style { case .app: + self.directionalLayoutMargins.trailing = self.stackView.directionalLayoutMargins.trailing + self.iconImageViewHeightConstraint.constant = 60 self.iconImageView.style = .icon self.titleLabel.textColor = .label + self.button.style = .pill + self.backgroundEffectView.contentView.backgroundColor = UIColor(resource: .blurTint) self.backgroundEffectView.backgroundColor = tintColor case .source: + self.directionalLayoutMargins.trailing = 20 + self.iconImageViewHeightConstraint.constant = 44 self.iconImageView.style = .circular self.titleLabel.textColor = .white + self.button.style = .custom + self.backgroundEffectView.contentView.backgroundColor = tintColor?.adjustedForDisplay self.backgroundEffectView.backgroundColor = nil diff --git a/AltStore/Extensions/UIFontDescriptor+Bold.swift b/AltStore/Extensions/UIFontDescriptor+Bold.swift new file mode 100644 index 00000000..94dbf155 --- /dev/null +++ b/AltStore/Extensions/UIFontDescriptor+Bold.swift @@ -0,0 +1,18 @@ +// +// UIFontDescriptor+Bold.swift +// AltStore +// +// Created by Riley Testut on 10/16/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit + +extension UIFontDescriptor +{ + func bolded() -> UIFontDescriptor + { + guard let descriptor = self.withSymbolicTraits(.traitBold) else { return self } + return descriptor + } +} diff --git a/AltStore/News/NewsViewController.swift b/AltStore/News/NewsViewController.swift index 726afb3d..2e2a6bf0 100644 --- a/AltStore/News/NewsViewController.swift +++ b/AltStore/News/NewsViewController.swift @@ -133,11 +133,6 @@ class NewsViewController: UICollectionViewController, PeekPopPreviewing self.collectionView.contentInset.bottom = 20 } } - - @IBAction private func unwindFromSourcesViewController(_ segue: UIStoryboardSegue) - { - self.fetchSource() - } } private extension NewsViewController diff --git a/AltStore/Resources/Assets.xcassets/Tabs/Contents.json b/AltStore/Resources/Assets.xcassets/Tabs/Contents.json index da4a164c..73c00596 100644 --- a/AltStore/Resources/Assets.xcassets/Tabs/Contents.json +++ b/AltStore/Resources/Assets.xcassets/Tabs/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/AltStore/Resources/Assets.xcassets/Tabs/Browse.imageset/Combined Shape.png b/AltStore/Resources/Assets.xcassets/Tabs/Sources.imageset/Combined Shape.png similarity index 100% rename from AltStore/Resources/Assets.xcassets/Tabs/Browse.imageset/Combined Shape.png rename to AltStore/Resources/Assets.xcassets/Tabs/Sources.imageset/Combined Shape.png diff --git a/AltStore/Resources/Assets.xcassets/Tabs/Browse.imageset/Combined Shape@2x.png b/AltStore/Resources/Assets.xcassets/Tabs/Sources.imageset/Combined Shape@2x.png similarity index 100% rename from AltStore/Resources/Assets.xcassets/Tabs/Browse.imageset/Combined Shape@2x.png rename to AltStore/Resources/Assets.xcassets/Tabs/Sources.imageset/Combined Shape@2x.png diff --git a/AltStore/Resources/Assets.xcassets/Tabs/Browse.imageset/Combined Shape@3x.png b/AltStore/Resources/Assets.xcassets/Tabs/Sources.imageset/Combined Shape@3x.png similarity index 100% rename from AltStore/Resources/Assets.xcassets/Tabs/Browse.imageset/Combined Shape@3x.png rename to AltStore/Resources/Assets.xcassets/Tabs/Sources.imageset/Combined Shape@3x.png diff --git a/AltStore/Resources/Assets.xcassets/Tabs/Browse.imageset/Contents.json b/AltStore/Resources/Assets.xcassets/Tabs/Sources.imageset/Contents.json similarity index 100% rename from AltStore/Resources/Assets.xcassets/Tabs/Browse.imageset/Contents.json rename to AltStore/Resources/Assets.xcassets/Tabs/Sources.imageset/Contents.json diff --git a/AltStore/Sources/Sources.storyboard b/AltStore/Sources/Sources.storyboard index d3e81888..e3014666 100644 --- a/AltStore/Sources/Sources.storyboard +++ b/AltStore/Sources/Sources.storyboard @@ -1,150 +1,41 @@ - + - + - + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -161,13 +52,13 @@ + - @@ -177,13 +68,13 @@ + - @@ -223,16 +114,16 @@ + - - + @@ -249,11 +140,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AltStore/Sources/SourcesViewController.swift b/AltStore/Sources/SourcesViewController.swift index 67d5e8c4..0c4faf1a 100644 --- a/AltStore/Sources/SourcesViewController.swift +++ b/AltStore/Sources/SourcesViewController.swift @@ -11,6 +11,7 @@ import CoreData import AltStoreCore import Roxas +import Nuke struct SourceError: ALTLocalizedError { @@ -40,151 +41,170 @@ private final class SourcesFooterView: TextCollectionReusableView @IBOutlet var textView: UITextView! } -extension SourcesViewController +private extension UIAction.Identifier { - private enum Section: Int, CaseIterable - { - case added - case trusted - } + static let showDetails = UIAction.Identifier("io.altstore.showDetails") + static let showError = UIAction.Identifier("io.altstore.showError") } final class SourcesViewController: UICollectionViewController { var deepLinkSourceURL: URL? { didSet { - guard let sourceURL = self.deepLinkSourceURL else { return } - self.addSource(url: sourceURL) + self.handleAddSourceDeepLink() } } private lazy var dataSource = self.makeDataSource() - private lazy var addedSourcesDataSource = self.makeAddedSourcesDataSource() - private lazy var trustedSourcesDataSource = self.makeTrustedSourcesDataSource() - private var fetchTrustedSourcesOperation: UpdateKnownSourcesOperation? - private var fetchTrustedSourcesResult: Result? - private var _fetchTrustedSourcesContext: NSManagedObjectContext? - + private lazy var dateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .short + dateFormatter.timeStyle = .none + return dateFormatter + }() + + private var placeholderView: RSTPlaceholderView! + private var placeholderViewButton: UIButton! + private var placeholderViewCenterYConstraint: NSLayoutConstraint! + override func viewDidLoad() { super.viewDidLoad() - self.view.tintColor = .altPrimary + let layout = self.makeLayout() + self.collectionView.collectionViewLayout = layout + self.navigationController?.view.tintColor = .altPrimary - if let navigationBar = self.navigationController?.navigationBar as? NavigationBar - { - // Don't automatically adjust item positions when being presented non-full screen, - // or else the navigation bar content won't be vertically centered. - navigationBar.automaticallyAdjustsItemPositions = false - } + self.collectionView.register(AppBannerCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier) + self.collectionView.register(UICollectionViewListCell.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: UICollectionView.elementKindSectionHeader) self.collectionView.dataSource = self.dataSource + self.collectionView.prefetchDataSource = self.dataSource + self.collectionView.allowsSelectionDuringEditing = false - #if !BETA - // Hide "Add Source" button for public version while in beta. - self.navigationItem.leftBarButtonItem = nil - #endif - } - - override func viewWillAppear(_ animated: Bool) - { - super.viewWillAppear(animated) + let backgroundView = UIView(frame: .zero) + backgroundView.backgroundColor = .altBackground + self.collectionView.backgroundView = backgroundView - if self.deepLinkSourceURL != nil - { - self.navigationItem.leftBarButtonItem?.isIndicatingActivity = true - } + self.placeholderView = RSTPlaceholderView(frame: .zero) + self.placeholderView.translatesAutoresizingMaskIntoConstraints = false + self.placeholderView.textLabel.text = NSLocalizedString("Add More Sources!", comment: "") + self.placeholderView.detailTextLabel.text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis massa tortor, tempor vel est vitae, consequat luctus arcu." + backgroundView.addSubview(self.placeholderView) - if self.fetchTrustedSourcesOperation == nil - { - self.fetchTrustedSources() - } + let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .title3).bolded() + self.placeholderView.textLabel.font = UIFont(descriptor: fontDescriptor, size: 0.0) + self.placeholderView.detailTextLabel.font = UIFont.preferredFont(forTextStyle: .body) + self.placeholderView.detailTextLabel.textAlignment = .natural + + self.placeholderViewButton = UIButton(type: .system, primaryAction: UIAction(title: NSLocalizedString("View Recommended Sources", comment: "")) { [weak self] _ in + self?.performSegue(withIdentifier: "addSource", sender: nil) + }) + self.placeholderViewButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) + self.placeholderView.stackView.spacing = 15 + self.placeholderView.stackView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 15, leading: 15, bottom: 15, trailing: 15) + self.placeholderView.stackView.isLayoutMarginsRelativeArrangement = true + self.placeholderView.stackView.addArrangedSubview(self.placeholderViewButton) + + self.placeholderViewCenterYConstraint = self.placeholderView.safeAreaLayoutGuide.centerYAnchor.constraint(equalTo: backgroundView.centerYAnchor, constant: 0) + + NSLayoutConstraint.activate([ + self.placeholderViewCenterYConstraint, + self.placeholderView.centerXAnchor.constraint(equalTo: backgroundView.centerXAnchor), + self.placeholderView.leadingAnchor.constraint(equalTo: backgroundView.leadingAnchor), + self.placeholderView.trailingAnchor.constraint(equalTo: backgroundView.trailingAnchor), + + self.placeholderView.leadingAnchor.constraint(equalTo: self.placeholderView.stackView.leadingAnchor), + self.placeholderView.trailingAnchor.constraint(equalTo: self.placeholderView.stackView.trailingAnchor), + self.placeholderView.topAnchor.constraint(equalTo: self.placeholderView.stackView.topAnchor), + self.placeholderView.bottomAnchor.constraint(equalTo: self.placeholderView.stackView.bottomAnchor), + ]) + + self.navigationItem.rightBarButtonItem = self.editButtonItem + + self.update() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - if let sourceURL = self.deepLinkSourceURL + self.handleAddSourceDeepLink() + } + + override func viewDidLayoutSubviews() + { + super.viewDidLayoutSubviews() + + // Vertically center placeholder view in gap below first item. + + let indexPath = IndexPath(item: 0, section: 0) + guard let layoutAttributes = self.collectionView.layoutAttributesForItem(at: indexPath) else { return } + + let maxY = layoutAttributes.frame.maxY + + let constant = maxY / 2 + if self.placeholderViewCenterYConstraint.constant != constant { - self.addSource(url: sourceURL) + self.placeholderViewCenterYConstraint.constant = constant } } } private extension SourcesViewController { - func makeDataSource() -> RSTCompositeCollectionViewDataSource + func makeLayout() -> UICollectionViewCompositionalLayout { - let dataSource = RSTCompositeCollectionViewDataSource(dataSources: [self.addedSourcesDataSource, self.trustedSourcesDataSource]) - dataSource.proxy = self - dataSource.cellConfigurationHandler = { [weak self] (cell, source, indexPath) in - guard let self else { return } + var configuration = UICollectionLayoutListConfiguration(appearance: .grouped) + configuration.headerMode = .supplementary + configuration.showsSeparators = false + configuration.backgroundColor = .clear + + configuration.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in + guard let self else { return UISwipeActionsConfiguration(actions: []) } - let tintColor = UIColor.altPrimary + let source = self.dataSource.item(at: indexPath) + var actions: [UIContextualAction] = [] - let cell = cell as! AppBannerCollectionViewCell - cell.layoutMargins.left = self.view.layoutMargins.left - cell.layoutMargins.right = self.view.layoutMargins.right - cell.tintColor = tintColor - - cell.bannerView.iconImageView.isHidden = true - cell.bannerView.buttonLabel.isHidden = true - cell.bannerView.button.isIndicatingActivity = false - - switch Section.allCases[indexPath.section] + if source.identifier != Source.altStoreIdentifier { - case .added: - cell.bannerView.button.isHidden = true + // Prevent users from removing AltStore source. - case .trusted: - // Quicker way to determine whether a source is already added than by reading from disk. - if (self.addedSourcesDataSource.fetchedResultsController.fetchedObjects ?? []).contains(where: { $0.identifier == source.identifier }) - { - // Source exists in .added section, so hide the button. - cell.bannerView.button.isHidden = true - - let configuation = UIImage.SymbolConfiguration(pointSize: 24) - - let imageAttachment = NSTextAttachment() - imageAttachment.image = UIImage(systemName: "checkmark.circle", withConfiguration: configuation)?.withTintColor(.altPrimary) - - let attributedText = NSAttributedString(attachment: imageAttachment) - cell.bannerView.buttonLabel.attributedText = attributedText - cell.bannerView.buttonLabel.textAlignment = .center - cell.bannerView.buttonLabel.isHidden = false - } - else - { - // Source does not exist in .added section, so show the button. - cell.bannerView.button.isHidden = false - cell.bannerView.buttonLabel.attributedText = nil + let removeAction = UIContextualAction(style: .destructive, + title: NSLocalizedString("Remove", comment: "")) { _, _, completion in + self.remove(source, completionHandler: completion) } + removeAction.image = UIImage(systemName: "trash.fill") - cell.bannerView.button.setTitle(NSLocalizedString("ADD", comment: ""), for: .normal) - cell.bannerView.button.addTarget(self, action: #selector(SourcesViewController.addTrustedSource(_:)), for: .primaryActionTriggered) + actions.append(removeAction) } - - cell.bannerView.titleLabel.text = source.name - cell.bannerView.subtitleLabel.text = source.sourceURL.absoluteString - cell.bannerView.subtitleLabel.numberOfLines = 2 - cell.errorBadge?.isHidden = (source.error == nil) + if let error = source.error + { + let viewErrorAction = UIContextualAction(style: .normal, + title: NSLocalizedString("View Error", comment: "")) { _, _, completion in + self.present(error) + completion(true) + } + viewErrorAction.backgroundColor = .systemYellow + viewErrorAction.image = UIImage(systemName: "exclamationmark.circle.fill") + + actions.append(viewErrorAction) + } - let attributedLabel = NSAttributedString(string: source.name + "\n" + source.sourceURL.absoluteString, attributes: [.accessibilitySpeechPunctuation: true]) - cell.bannerView.accessibilityAttributedLabel = attributedLabel - cell.bannerView.accessibilityTraits.remove(.button) + let config = UISwipeActionsConfiguration(actions: actions) + config.performsFirstActionWithFullSwipe = false - // Make sure refresh button is correct size. - cell.layoutIfNeeded() + return config } - return dataSource + let layout = UICollectionViewCompositionalLayout.list(using: configuration) + return layout } - func makeAddedSourcesDataSource() -> RSTFetchedResultsCollectionViewDataSource + func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource { let fetchRequest = Source.fetchRequest() as NSFetchRequest fetchRequest.returnsObjectsAsFaults = false @@ -195,13 +215,129 @@ private extension SourcesViewController NSSortDescriptor(keyPath: \Source.identifier, ascending: true)] - let dataSource = RSTFetchedResultsCollectionViewDataSource(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext) - return dataSource - } - - func makeTrustedSourcesDataSource() -> RSTArrayCollectionViewDataSource - { - let dataSource = RSTArrayCollectionViewDataSource(items: []) + let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: nil, cacheName: nil) + fetchedResultsController.delegate = self + + let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource(fetchedResultsController: fetchedResultsController) + dataSource.proxy = self + dataSource.cellConfigurationHandler = { [weak self] (cell, source, indexPath) in + guard let self else { return } + + let cell = cell as! AppBannerCollectionViewCell + cell.layoutMargins.top = 5 + cell.layoutMargins.bottom = 5 + cell.layoutMargins.left = self.view.layoutMargins.left + cell.layoutMargins.right = self.view.layoutMargins.right + + cell.bannerView.configure(for: source) + + cell.bannerView.iconImageView.image = nil + cell.bannerView.iconImageView.isIndicatingActivity = true + + let numberOfApps: Int + if let patreonAccount = DatabaseManager.shared.patreonAccount(), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated + { + numberOfApps = source.apps.count + } + else + { + numberOfApps = source.apps.filter { !$0.isBeta }.count + } + + if let error = source.error + { + let image = UIImage(systemName: "exclamationmark")?.withTintColor(.white, renderingMode: .alwaysOriginal) + + cell.bannerView.button.setImage(image, for: .normal) + cell.bannerView.button.setTitle(nil, for: .normal) + cell.bannerView.button.tintColor = .systemYellow.withAlphaComponent(0.75) + + let action = UIAction(identifier: .showError) { _ in + self.present(error) + } + cell.bannerView.button.addAction(action, for: .primaryActionTriggered) + cell.bannerView.button.removeAction(identifiedBy: .showDetails, for: .primaryActionTriggered) + } + else + { + cell.bannerView.button.setImage(nil, for: .normal) + cell.bannerView.button.setTitle(numberOfApps.description, for: .normal) + cell.bannerView.button.tintColor = .white.withAlphaComponent(0.2) + + let action = UIAction(identifier: .showDetails) { _ in + self.showSourceDetails(for: source) + } + cell.bannerView.button.addAction(action, for: .primaryActionTriggered) + cell.bannerView.button.removeAction(identifiedBy: .showError, for: .primaryActionTriggered) + } + + let dateText: String + if let lastUpdatedDate = source.lastUpdatedDate + { + dateText = Date().relativeDateString(since: lastUpdatedDate, dateFormatter: self.dateFormatter) + } + else + { + dateText = NSLocalizedString("Never", comment: "") + } + + let text = String(format: NSLocalizedString("Last Updated: %@", comment: ""), dateText) + cell.bannerView.subtitleLabel.text = text + cell.bannerView.subtitleLabel.numberOfLines = 1 + + let numberOfAppsText: String + if #available(iOS 15, *) + { + let attributedOutput = AttributedString(localized: "^[\(numberOfApps) app](inflect: true)") + numberOfAppsText = String(attributedOutput.characters) + } + else + { + numberOfAppsText = "" + } + + let accessibilityLabel = source.name + "\n" + text + ".\n" + numberOfAppsText + cell.bannerView.accessibilityLabel = accessibilityLabel + + if source.identifier != Source.altStoreIdentifier + { + cell.accessories = [.delete(displayed: .whenEditing)] + } + else + { + cell.accessories = [] + } + + cell.bannerView.accessibilityTraits.remove(.button) + + // Make sure refresh button is correct size. + cell.layoutIfNeeded() + } + dataSource.prefetchHandler = { (source, indexPath, completionHandler) in + guard let imageURL = source.effectiveIconURL else { return nil } + return RSTAsyncBlockOperation() { (operation) in + ImagePipeline.shared.loadImage(with: imageURL, progress: nil) { result in + guard !operation.isCancelled else { return operation.finish() } + + switch result + { + case .success(let response): completionHandler(response.image, nil) + case .failure(let error): completionHandler(nil, error) + } + } + } + } + dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in + let cell = cell as! AppBannerCollectionViewCell + cell.bannerView.iconImageView.isIndicatingActivity = false + cell.bannerView.iconImageView.image = image + + if let error = error + { + print("Error loading image:", error) + } + } + return dataSource } @@ -217,7 +353,7 @@ private extension SourcesViewController private extension SourcesViewController { - @IBAction func addSource() + func handleAddSourceDeepLink() { let alertController = UIAlertController(title: NSLocalizedString("Add Source", comment: ""), message: nil, preferredStyle: .alert) alertController.addTextField { (textField) in @@ -239,19 +375,12 @@ private extension SourcesViewController self.navigationItem.leftBarButtonItem?.isIndicatingActivity = false } }) + guard let url = self.deepLinkSourceURL, self.view.window != nil else { return } - self.present(alertController, animated: true, completion: nil) - } - - func addSource(url: URL, completionHandler: ((Result) -> Void)? = nil) - { - guard self.view.window != nil else { return } + // Only handle deep link once. + self.deepLinkSourceURL = nil - if url == self.deepLinkSourceURL - { - // Only handle deep link once. - self.deepLinkSourceURL = nil - } + self.navigationItem.leftBarButtonItem?.isIndicatingActivity = true func finish(_ result: Result) { @@ -270,48 +399,22 @@ private extension SourcesViewController self.present(error.withLocalizedTitle(NSLocalizedString("Unable to Add Source", comment: ""))) } - self.collectionView.reloadSections([Section.trusted.rawValue]) - - completionHandler?(result) + self.navigationItem.leftBarButtonItem?.isIndicatingActivity = false } } - var dependencies: [Foundation.Operation] = [] - if let fetchTrustedSourcesOperation = self.fetchTrustedSourcesOperation - { - // Must fetch trusted sources first to determine whether this is a trusted source. - // We assume fetchTrustedSources() has already been called before this method. - dependencies = [fetchTrustedSourcesOperation] - } - - AppManager.shared.fetchSource(sourceURL: url, dependencies: dependencies) { (result) in + AppManager.shared.fetchSource(sourceURL: url) { (result) in do { // Use @Managed before calling perform() to keep // strong reference to source.managedObjectContext. @Managed var source = try result.get() - let backgroundContext = DatabaseManager.shared.persistentContainer.newBackgroundContext() - backgroundContext.perform { - do - { - let predicate = NSPredicate(format: "%K == %@", #keyPath(Source.identifier), $source.identifier) - if let existingSource = Source.first(satisfying: predicate, in: backgroundContext) - { - throw SourceError.duplicate(source, existingSource: existingSource) - } - - DispatchQueue.main.async { - self.showSourceDetails(for: source) - } - - finish(.success(())) - } - catch - { - finish(.failure(error)) - } + DispatchQueue.main.async { + self.showSourceDetails(for: source) } + + finish(.success(())) } catch { @@ -340,42 +443,22 @@ private extension SourcesViewController self.present(alertController, animated: true, completion: nil) } - func fetchTrustedSources() + func remove(_ source: Source, completionHandler: ((Bool) -> Void)? = nil) { - // Closure instead of local function so we can capture `self` weakly. - let finish: (Result<[Source], Error>) -> Void = { [weak self] result in - self?.fetchTrustedSourcesResult = result.map { _ in () } - - DispatchQueue.main.async { - do - { - let sources = try result.get() - print("Fetched trusted sources:", sources.map { $0.identifier }) - - let sectionUpdate = RSTCellContentChange(type: .update, sectionIndex: 0) - self?.trustedSourcesDataSource.setItems(sources, with: [sectionUpdate]) - } - catch - { - print("Error fetching trusted sources:", error) - - let sectionUpdate = RSTCellContentChange(type: .update, sectionIndex: 0) - self?.trustedSourcesDataSource.setItems([], with: [sectionUpdate]) - } - } - } - - self.fetchTrustedSourcesOperation = AppManager.shared.updateKnownSources { [weak self] result in - switch result + Task { + do { - case .failure(let error): finish(.failure(error)) - case .success((let trustedSources, _)): - // Don't show sources without a sourceURL. - let featuredSourceURLs = trustedSources.compactMap { $0.sourceURL } + try await AppManager.shared.remove(source, presentingViewController: self) - // This context is never saved, but keeps the managed sources alive. - let context = DatabaseManager.shared.persistentContainer.newBackgroundSavingViewContext() - self?._fetchTrustedSourcesContext = context + completionHandler?(true) + } + catch is CancellationError + { + completionHandler?(false) + } + catch + { + completionHandler?(false) let dispatchGroup = DispatchGroup() @@ -410,60 +493,36 @@ private extension SourcesViewController let sources = featuredSourceURLs.compactMap { sourcesByURL[$0] } finish(.success(sources)) } + self.present(error) } } } - @IBAction func addTrustedSource(_ sender: PillButton) - { - let point = self.collectionView.convert(sender.center, from: sender.superview) - guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return } - - let completedProgress = Progress(totalUnitCount: 1) - completedProgress.completedUnitCount = 1 - sender.progress = completedProgress - - let source = self.dataSource.item(at: indexPath) - self.addSource(url: source.sourceURL) { _ in - //FIXME: Handle cell reuse. - sender.progress = nil - } - } - - func remove(_ source: Source) - { - let alertController = UIAlertController(title: String(format: NSLocalizedString("Are you sure you want to remove the source “%@”?", comment: ""), source.name), - message: NSLocalizedString("Any apps you've installed from this source will remain, but they'll no longer receive any app updates.", comment: ""), preferredStyle: .alert) - alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style, handler: nil)) - alertController.addAction(UIAlertAction(title: NSLocalizedString("Remove Source", comment: ""), style: .destructive) { _ in - DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in - let source = context.object(with: source.objectID) as! Source - context.delete(source) - - do - { - try context.save() - - DispatchQueue.main.async { - self.collectionView.reloadSections([Section.trusted.rawValue]) - } - } - catch - { - DispatchQueue.main.async { - self.present(error) - } - } - } - }) - - self.present(alertController, animated: true, completion: nil) - } - func showSourceDetails(for source: Source) { self.performSegue(withIdentifier: "showSourceDetails", sender: source) } + + func update() + { + if self.dataSource.itemCount < 2 + { + // Show placeholder view + + self.placeholderView.isHidden = false + self.collectionView.alwaysBounceVertical = false + + self.setEditing(false, animated: true) + self.editButtonItem.isEnabled = false + } + else + { + self.placeholderView.isHidden = true + self.collectionView.alwaysBounceVertical = true + + self.editButtonItem.isEnabled = true + } + } } extension SourcesViewController @@ -475,55 +534,17 @@ extension SourcesViewController let source = self.dataSource.item(at: indexPath) self.showSourceDetails(for: source) } -} - -extension SourcesViewController: UICollectionViewDelegateFlowLayout -{ - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize - { - return CGSize(width: collectionView.bounds.width, height: 80) - } - - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize - { - let indexPath = IndexPath(row: 0, section: section) - let headerView = self.collectionView(collectionView, viewForSupplementaryElementOfKind: UICollectionView.elementKindSectionHeader, at: indexPath) - - // Use this view to calculate the optimal size based on the collection view's width - let size = headerView.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingExpandedSize.height), - withHorizontalFittingPriority: .required, // Width is fixed - verticalFittingPriority: .fittingSizeLevel) // Height can be as large as needed - return size - } - - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize - { - guard Section(rawValue: section) == .trusted else { return .zero } - - let indexPath = IndexPath(row: 0, section: section) - let headerView = self.collectionView(collectionView, viewForSupplementaryElementOfKind: UICollectionView.elementKindSectionFooter, at: indexPath) - - // Use this view to calculate the optimal size based on the collection view's width - let size = headerView.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingExpandedSize.height), - withHorizontalFittingPriority: .required, // Width is fixed - verticalFittingPriority: .fittingSizeLevel) // Height can be as large as needed - return size - } override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { - let reuseIdentifier = (kind == UICollectionView.elementKindSectionHeader) ? "Header" : "Footer" + let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! UICollectionViewListCell - let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: reuseIdentifier, for: indexPath) as! TextCollectionReusableView - headerView.layoutMargins.left = self.view.layoutMargins.left - headerView.layoutMargins.right = self.view.layoutMargins.right + var configuation = UIListContentConfiguration.cell() + configuation.text = NSLocalizedString("Sources control what apps are available to download through AltStore.", comment: "") + configuation.textProperties.color = .secondaryLabel + configuation.textProperties.alignment = .natural - /* Changing NSLayoutConstraint priorities from required to optional (and vice versa) isn’t supported, and crashes on iOS 12. */ - // let almostRequiredPriority = UILayoutPriority(UILayoutPriority.required.rawValue - 1) // Can't be required or else we can't satisfy constraints when hidden (size = 0). - // headerView.leadingLayoutConstraint?.priority = almostRequiredPriority - // headerView.trailingLayoutConstraint?.priority = almostRequiredPriority - // headerView.topLayoutConstraint?.priority = almostRequiredPriority - // headerView.bottomLayoutConstraint?.priority = almostRequiredPriority + headerView.contentConfiguration = configuation switch kind { @@ -607,78 +628,79 @@ extension SourcesViewController: UICollectionViewDelegateFlowLayout } } -extension SourcesViewController +extension SourcesViewController: NSFetchedResultsControllerDelegate { - override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? + func controllerWillChangeContent(_ controller: NSFetchedResultsController) { - let source = self.dataSource.item(at: indexPath) + self.dataSource.controllerWillChangeContent(controller) + } + + func controllerDidChangeContent(_ controller: NSFetchedResultsController) + { + self.update() + + self.dataSource.controllerDidChangeContent(controller) + } + + func controller(_ controller: NSFetchedResultsController, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) + { + self.dataSource.controller(controller, didChange: anObject, at: indexPath, for: type, newIndexPath: newIndexPath) + } + + func controller(_ controller: NSFetchedResultsController, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) + { + self.dataSource.controller(controller, didChange: sectionInfo, atSectionIndex: UInt(sectionIndex), for: type) + } +} - return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: nil) { (suggestedActions) -> UIMenu? in - let viewErrorAction = UIAction(title: NSLocalizedString("View Error", comment: ""), image: UIImage(systemName: "exclamationmark.circle")) { (action) in - guard let error = source.error else { return } - self.present(error) - } - - let deleteAction = UIAction(title: NSLocalizedString("Remove", comment: ""), image: UIImage(systemName: "trash"), attributes: [.destructive]) { (action) in - self.remove(source) - } - - let addAction = UIAction(title: String(format: NSLocalizedString("Add “%@”", comment: ""), source.name), image: UIImage(systemName: "plus")) { (action) in - self.addSource(url: source.sourceURL) - } - - var actions: [UIAction] = [] - - if source.error != nil - { - actions.append(viewErrorAction) - } - - switch Section.allCases[indexPath.section] - { - case .added: - if source.identifier != Source.altStoreIdentifier - { - actions.append(deleteAction) - } - - case .trusted: - if let cell = collectionView.cellForItem(at: indexPath) as? AppBannerCollectionViewCell, !cell.bannerView.button.isHidden - { - actions.append(addAction) - } - } - - guard !actions.isEmpty else { return nil } - - let menu = UIMenu(title: "", children: actions) - return menu +@available(iOS 17, *) +#Preview(traits: .portrait) { + DatabaseManager.shared.startForPreview() + + let storyboard = UIStoryboard(name: "Sources", bundle: nil) + let sourcesViewController = storyboard.instantiateInitialViewController()! + + let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() + context.performAndWait { + _ = Source.make(name: "OatmealDome's AltStore Source", + identifier: "me.oatmealdome.altstore", + sourceURL: URL(string: "https://altstore.oatmealdome.me")!, + context: context) + + _ = Source.make(name: "UTM Repository", + identifier: "com.utmapp.repos.UTM", + sourceURL: URL(string: "https://alt.getutm.app")!, + context: context) + + _ = Source.make(name: "Flyinghead", + identifier: "com.flyinghead.source", + sourceURL: URL(string: "https://flyinghead.github.io/flycast-builds/altstore.json")!, + context: context) + + _ = Source.make(name: "Provenance", + identifier: "org.provenance-emu.AltStore", + sourceURL: URL(string: "https://provenance-emu.com/apps.json")!, + context: context) + + _ = Source.make(name: "PojavLauncher Repository", + identifier: "dev.crystall1ne.repos.PojavLauncher", + sourceURL: URL(string: "http://alt.crystall1ne.dev")!, + context: context) + + try! context.save() + } + + AppManager.shared.fetchSources { result in + do + { + let (sources, context) = try result.get() + try context.save() + } + catch + { + print("Preview failed to fetch sources:", error) } } - - override func collectionView(_ collectionView: UICollectionView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? - { - guard let indexPath = configuration.identifier as? NSIndexPath else { return nil } - guard let cell = collectionView.cellForItem(at: indexPath as IndexPath) as? AppBannerCollectionViewCell else { return nil } - - let parameters = UIPreviewParameters() - parameters.backgroundColor = .clear - parameters.visiblePath = UIBezierPath(roundedRect: cell.bannerView.bounds, cornerRadius: cell.bannerView.layer.cornerRadius) - - let preview = UITargetedPreview(view: cell.bannerView, parameters: parameters) - return preview - } - - override func collectionView(_ collectionView: UICollectionView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? - { - return self.collectionView(collectionView, previewForHighlightingContextMenuWithConfiguration: configuration) - } -} - -extension SourcesViewController: UITextViewDelegate -{ - func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool - { - return true - } + + return sourcesViewController } diff --git a/AltStore/TabBarController.swift b/AltStore/TabBarController.swift index 57d5f1ac..2a72d601 100644 --- a/AltStore/TabBarController.swift +++ b/AltStore/TabBarController.swift @@ -14,6 +14,7 @@ extension TabBarController private enum Tab: Int, CaseIterable { case news + case sources case browse case myApps case settings @@ -26,6 +27,8 @@ final class TabBarController: UITabBarController private var _viewDidAppear = false + private var sourcesViewController: SourcesViewController! + required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) @@ -36,6 +39,14 @@ final class TabBarController: UITabBarController NotificationCenter.default.addObserver(self, selector: #selector(TabBarController.openErrorLog(_:)), name: ToastView.openErrorLogNotification, object: nil) } + override func viewDidLoad() + { + super.viewDidLoad() + + let sourcesNavigationController = self.viewControllers![Tab.sources.rawValue] as! UINavigationController + self.sourcesViewController = sourcesNavigationController.viewControllers.first as? SourcesViewController + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) @@ -63,15 +74,6 @@ final class TabBarController: UITabBarController switch identifier { - case "presentSources": - guard let notification = sender as? Notification, - let sourceURL = notification.userInfo?[AppDelegate.addSourceDeepLinkURLKey] as? URL - else { return } - - let navigationController = segue.destination as! UINavigationController - let sourcesViewController = navigationController.viewControllers.first as! SourcesViewController - sourcesViewController.deepLinkSourceURL = sourceURL - case "finishJailbreak": guard let installedApp = sender as? InstalledApp else { return } @@ -104,30 +106,19 @@ extension TabBarController { if let presentedViewController = self.presentedViewController { - if let navigationController = presentedViewController as? UINavigationController, - let sourcesViewController = navigationController.viewControllers.first as? SourcesViewController - { - if let notification = (sender as? Notification), - let sourceURL = notification.userInfo?[AppDelegate.addSourceDeepLinkURLKey] as? URL - { - sourcesViewController.deepLinkSourceURL = sourceURL - } - else - { - // Don't dismiss SourcesViewController if it's already presented. - } - } - else - { - presentedViewController.dismiss(animated: true) { - self.presentSources(sender) - } + presentedViewController.dismiss(animated: true) { + self.presentSources(sender) } return } + + if let notification = (sender as? Notification), let sourceURL = notification.userInfo?[AppDelegate.addSourceDeepLinkURLKey] as? URL + { + self.sourcesViewController?.deepLinkSourceURL = sourceURL + } - self.performSegue(withIdentifier: "presentSources", sender: sender) + self.selectedIndex = Tab.sources.rawValue } } diff --git a/AltStoreCore/Model/Source.swift b/AltStoreCore/Model/Source.swift index e22e46e8..bd883dec 100644 --- a/AltStoreCore/Model/Source.swift +++ b/AltStoreCore/Model/Source.swift @@ -386,6 +386,13 @@ public extension Source } return isRecommended } + + var lastUpdatedDate: Date? { + let allDates = self.apps.compactMap { $0.latestAvailableVersion?.date } + self.newsItems.map { $0.date } + + let lastUpdatedDate = allDates.sorted().last + return lastUpdatedDate + } } internal extension Source @@ -433,4 +440,14 @@ public extension Source let source = Source.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Source.identifier), Source.altStoreIdentifier), in: context) return source } + + class func make(name: String, identifier: String, sourceURL: URL, context: NSManagedObjectContext) -> Source + { + let source = Source(context: context) + source.name = name + source.identifier = identifier + source.sourceURL = sourceURL + + return source + } }