mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-10 07:13:28 +01:00
Adds AddSourceViewController to add sources by URL or from list of recommended sources
This commit is contained in:
@@ -342,6 +342,7 @@
|
||||
BFF435D8255CBDAB00DD724F /* ALTApplication+AltStoreApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF435D7255CBDAB00DD724F /* ALTApplication+AltStoreApp.swift */; };
|
||||
BFF615A82510042B00484D3B /* AltStoreCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF66EE7E2501AE50007EE018 /* AltStoreCore.framework */; };
|
||||
BFF7C9342578492100E55F36 /* ALTAnisetteData.m in Sources */ = {isa = PBXBuildFile; fileRef = BFB49AA823834CF900D542D9 /* ALTAnisetteData.m */; };
|
||||
D50107EC2ADF2E1A0069F2A1 /* AddSourceTextFieldCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D50107EB2ADF2E1A0069F2A1 /* AddSourceTextFieldCell.swift */; };
|
||||
D513F6162A12CE4E0061EAA1 /* SourceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D571ADCD2A02FA7400B24B63 /* SourceError.swift */; };
|
||||
D5151BD92A8FF64300C96F28 /* RefreshAllAppsIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5151BD82A8FF64300C96F28 /* RefreshAllAppsIntent.swift */; };
|
||||
D5151BE12A90344300C96F28 /* RefreshAllAppsWidgetIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5151BE02A90344300C96F28 /* RefreshAllAppsWidgetIntent.swift */; };
|
||||
@@ -362,6 +363,7 @@
|
||||
D533E8BE2727BBF800A9B5DD /* libcurl.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D533E8BD2727BBF800A9B5DD /* libcurl.a */; };
|
||||
D537C8592AA94D94009A1E08 /* altjit in Embed AltJIT */ = {isa = PBXBuildFile; fileRef = D5FB7A132AA284BE00EF863D /* altjit */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
D537C85B2AA9507A009A1E08 /* libcorecrypto.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D537C85A2AA95066009A1E08 /* libcorecrypto.tbd */; platformFilters = (macos, ); };
|
||||
D5390C3C2AC3A43900D17E62 /* AddSourceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5390C3B2AC3A43900D17E62 /* AddSourceViewController.swift */; };
|
||||
D53D84022A2158FC00543C3B /* Permissions.plist in Resources */ = {isa = PBXBuildFile; fileRef = D53D84012A2158FC00543C3B /* Permissions.plist */; };
|
||||
D54058B92A1D6269008CCC58 /* AppPermissionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54058B82A1D6269008CCC58 /* AppPermissionProtocol.swift */; };
|
||||
D54058BB2A1D8FE3008CCC58 /* UIColor+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */; };
|
||||
@@ -394,7 +396,7 @@
|
||||
D59162AD29BA616A005CBF47 /* SourceHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D59162AC29BA616A005CBF47 /* SourceHeaderView.xib */; };
|
||||
D5927D6629DCC89000D6898E /* UINavigationBarAppearance+TintColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5927D6529DCC89000D6898E /* UINavigationBarAppearance+TintColor.swift */; };
|
||||
D5927D6929DCE28700D6898E /* AltStore11ToAltStore12.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = D5927D6829DCE28700D6898E /* AltStore11ToAltStore12.xcmappingmodel */; };
|
||||
D5935AED29C39DE300C157EF /* SourceDetailsComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5935AEC29C39DE300C157EF /* SourceDetailsComponents.swift */; };
|
||||
D5935AED29C39DE300C157EF /* SourceComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5935AEC29C39DE300C157EF /* SourceComponents.swift */; };
|
||||
D5935AEF29C3B23600C157EF /* Sources.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D5935AEE29C3B23600C157EF /* Sources.storyboard */; };
|
||||
D593F1942717749A006E82DE /* PatchAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D593F1932717749A006E82DE /* PatchAppOperation.swift */; };
|
||||
D59A6B7B2AA91B8E00F61259 /* PythonCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D59A6B7A2AA91B8E00F61259 /* PythonCommand.swift */; };
|
||||
@@ -1007,6 +1009,7 @@
|
||||
D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = "<group>"; };
|
||||
D52E988928D002D30032BE6B /* AltStore 11.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AltStore 11.xcdatamodel"; sourceTree = "<group>"; };
|
||||
C9EEAA842DA87A88A870053B /* Pods_AltStore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AltStore.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D50107EB2ADF2E1A0069F2A1 /* AddSourceTextFieldCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddSourceTextFieldCell.swift; sourceTree = "<group>"; };
|
||||
D5151BD82A8FF64300C96F28 /* RefreshAllAppsIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshAllAppsIntent.swift; sourceTree = "<group>"; };
|
||||
D5151BE02A90344300C96F28 /* RefreshAllAppsWidgetIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshAllAppsWidgetIntent.swift; sourceTree = "<group>"; };
|
||||
D5151BE52A90391900C96F28 /* View+AltWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+AltWidget.swift"; sourceTree = "<group>"; };
|
||||
@@ -1023,6 +1026,7 @@
|
||||
D533E8BB2727BBEE00A9B5DD /* libfragmentzip.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libfragmentzip.a; path = Dependencies/fragmentzip/libfragmentzip.a; sourceTree = SOURCE_ROOT; };
|
||||
D533E8BD2727BBF800A9B5DD /* libcurl.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libcurl.a; path = Dependencies/libcurl/libcurl.a; sourceTree = SOURCE_ROOT; };
|
||||
D537C85A2AA95066009A1E08 /* libcorecrypto.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libcorecrypto.tbd; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX14.0.sdk/usr/lib/system/libcorecrypto.tbd; sourceTree = DEVELOPER_DIR; };
|
||||
D5390C3B2AC3A43900D17E62 /* AddSourceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddSourceViewController.swift; sourceTree = "<group>"; };
|
||||
D53D84012A2158FC00543C3B /* Permissions.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Permissions.plist; sourceTree = "<group>"; };
|
||||
D54058B82A1D6269008CCC58 /* AppPermissionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPermissionProtocol.swift; sourceTree = "<group>"; };
|
||||
D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+AltStore.swift"; sourceTree = "<group>"; };
|
||||
@@ -1057,7 +1061,7 @@
|
||||
D5927D6529DCC89000D6898E /* UINavigationBarAppearance+TintColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBarAppearance+TintColor.swift"; sourceTree = "<group>"; };
|
||||
D5927D6729DCE1FE00D6898E /* AltStore 12.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AltStore 12.xcdatamodel"; sourceTree = "<group>"; };
|
||||
D5927D6829DCE28700D6898E /* AltStore11ToAltStore12.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = AltStore11ToAltStore12.xcmappingmodel; sourceTree = "<group>"; };
|
||||
D5935AEC29C39DE300C157EF /* SourceDetailsComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceDetailsComponents.swift; sourceTree = "<group>"; };
|
||||
D5935AEC29C39DE300C157EF /* SourceComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceComponents.swift; sourceTree = "<group>"; };
|
||||
D5935AEE29C3B23600C157EF /* Sources.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Sources.storyboard; sourceTree = "<group>"; };
|
||||
D593F1932717749A006E82DE /* PatchAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchAppOperation.swift; sourceTree = "<group>"; };
|
||||
D59A6B7A2AA91B8E00F61259 /* PythonCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PythonCommand.swift; sourceTree = "<group>"; };
|
||||
@@ -1835,9 +1839,8 @@
|
||||
BFC84A4C2421A19100853474 /* SourcesViewController.swift */,
|
||||
D5CD805E29CA755E00E591B0 /* SourceDetailViewController.swift */,
|
||||
D5A0537229B91DB400997551 /* SourceDetailContentViewController.swift */,
|
||||
D5935AEC29C39DE300C157EF /* SourceDetailsComponents.swift */,
|
||||
D59162AA29BA60A9005CBF47 /* SourceHeaderView.swift */,
|
||||
D59162AC29BA616A005CBF47 /* SourceHeaderView.xib */,
|
||||
D5390C3B2AC3A43900D17E62 /* AddSourceViewController.swift */,
|
||||
D50107ED2ADF2E310069F2A1 /* Components */,
|
||||
);
|
||||
path = Sources;
|
||||
sourceTree = "<group>";
|
||||
@@ -2133,6 +2136,17 @@
|
||||
path = XPC;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D50107ED2ADF2E310069F2A1 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D5935AEC29C39DE300C157EF /* SourceComponents.swift */,
|
||||
D59162AA29BA60A9005CBF47 /* SourceHeaderView.swift */,
|
||||
D59162AC29BA616A005CBF47 /* SourceHeaderView.xib */,
|
||||
D50107EB2ADF2E1A0069F2A1 /* AddSourceTextFieldCell.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D50C29F22A8ECD71009AB488 /* Widgets */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -3149,6 +3163,7 @@
|
||||
D5E1E7C128077DE90016FC96 /* UpdateKnownSourcesOperation.swift in Sources */,
|
||||
BFE338DF22F0EADB002E24B9 /* FetchSourceOperation.swift in Sources */,
|
||||
D54DED1428CBC44B008B27A0 /* ErrorLogTableViewCell.swift in Sources */,
|
||||
D5390C3C2AC3A43900D17E62 /* AddSourceViewController.swift in Sources */,
|
||||
D5CD805F29CA755E00E591B0 /* SourceDetailViewController.swift in Sources */,
|
||||
D57968CB29CB99EF00539069 /* VibrantButton.swift in Sources */,
|
||||
BFB6B21E231870160022A802 /* NewsViewController.swift in Sources */,
|
||||
@@ -3161,7 +3176,7 @@
|
||||
BFCCB51A245E3401001853EA /* VerifyAppOperation.swift in Sources */,
|
||||
BFF0B6982322CAB8007A79E1 /* InstructionsViewController.swift in Sources */,
|
||||
BF9ABA4522DCFF43008935CF /* BrowseViewController.swift in Sources */,
|
||||
D5935AED29C39DE300C157EF /* SourceDetailsComponents.swift in Sources */,
|
||||
D5935AED29C39DE300C157EF /* SourceComponents.swift in Sources */,
|
||||
D5A0537329B91DB400997551 /* SourceDetailContentViewController.swift in Sources */,
|
||||
BF770E5422BC044E002A40FE /* OperationContexts.swift in Sources */,
|
||||
BFD2478C2284C4C300981D42 /* AppIconImageView.swift in Sources */,
|
||||
@@ -3214,6 +3229,7 @@
|
||||
BFC84A4D2421A19100853474 /* SourcesViewController.swift in Sources */,
|
||||
BFF0B696232242D3007A79E1 /* LicensesViewController.swift in Sources */,
|
||||
D57FE84428C7DB7100216002 /* ErrorLogViewController.swift in Sources */,
|
||||
D50107EC2ADF2E1A0069F2A1 /* AddSourceTextFieldCell.swift in Sources */,
|
||||
D5151BD92A8FF64300C96F28 /* RefreshAllAppsIntent.swift in Sources */,
|
||||
BFBE0007250AD0E70080826E /* ViewAppIntentHandler.swift in Sources */,
|
||||
BFDB6A0822AAED73007EA6D6 /* ResignAppOperation.swift in Sources */,
|
||||
|
||||
@@ -154,9 +154,21 @@ extension AppBannerView
|
||||
{
|
||||
self.style = .source
|
||||
|
||||
self.titleLabel.text = source.name
|
||||
let subtitle: String
|
||||
if let text = source.subtitle
|
||||
{
|
||||
subtitle = text
|
||||
}
|
||||
else if let scheme = source.sourceURL.scheme
|
||||
{
|
||||
subtitle = source.sourceURL.absoluteString.replacingOccurrences(of: scheme + "://", with: "")
|
||||
}
|
||||
else
|
||||
{
|
||||
subtitle = source.sourceURL.absoluteString
|
||||
}
|
||||
|
||||
let subtitle = source.subtitle ?? source.sourceURL.absoluteString
|
||||
self.titleLabel.text = source.name
|
||||
self.subtitleLabel.text = subtitle
|
||||
|
||||
let tintColor = source.effectiveTintColor ?? .altPrimary
|
||||
|
||||
@@ -66,6 +66,8 @@ class PillButton: UIButton
|
||||
|
||||
var style: Style = .pill {
|
||||
didSet {
|
||||
guard self.style != oldValue else { return }
|
||||
|
||||
if self.style == .custom
|
||||
{
|
||||
// Reset insets for custom style.
|
||||
|
||||
@@ -358,7 +358,7 @@ extension AppManager
|
||||
}
|
||||
}
|
||||
|
||||
func add(@AsyncManaged _ source: Source, message: String? = nil, presentingViewController: UIViewController) async throws
|
||||
func add(@AsyncManaged _ source: Source, message: String? = NSLocalizedString("Make sure to only add sources that you trust.", comment: ""), presentingViewController: UIViewController) async throws
|
||||
{
|
||||
let (sourceName, sourceURL) = await $source.perform { ($0.name, $0.sourceURL) }
|
||||
|
||||
@@ -366,9 +366,8 @@ extension AppManager
|
||||
async let fetchedSource = try await self.fetchSource(sourceURL: sourceURL, managedObjectContext: context) // Fetch source async while showing alert.
|
||||
|
||||
let title = String(format: NSLocalizedString("Would you like to add the source “%@”?", comment: ""), sourceName)
|
||||
let message = message ?? NSLocalizedString("Make sure to only add sources that you trust.", comment: "")
|
||||
let action = await UIAlertAction(title: NSLocalizedString("Add Source", comment: ""), style: .default)
|
||||
try await presentingViewController.presentConfirmationAlert(title: title, message: message, primaryAction: action)
|
||||
try await presentingViewController.presentConfirmationAlert(title: title, message: message ?? "", primaryAction: action)
|
||||
|
||||
// Wait for fetch to finish before saving context to make
|
||||
// sure there isn't already a source with this identifier.
|
||||
@@ -412,10 +411,11 @@ extension AppManager
|
||||
extension AppManager
|
||||
{
|
||||
@available(*, renamed: "fetchSource(sourceURL:managedObjectContext:)")
|
||||
@discardableResult
|
||||
func fetchSource(sourceURL: URL,
|
||||
managedObjectContext: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext(),
|
||||
dependencies: [Foundation.Operation] = [],
|
||||
completionHandler: @escaping (Result<Source, Error>) -> Void)
|
||||
completionHandler: @escaping (Result<Source, Error>) -> Void) -> FetchSourceOperation
|
||||
{
|
||||
let fetchSourceOperation = FetchSourceOperation(sourceURL: sourceURL, managedObjectContext: managedObjectContext)
|
||||
fetchSourceOperation.resultHandler = { (result) in
|
||||
@@ -435,6 +435,8 @@ extension AppManager
|
||||
}
|
||||
|
||||
self.run([fetchSourceOperation], context: nil)
|
||||
|
||||
return fetchSourceOperation
|
||||
}
|
||||
|
||||
@available(*, renamed: "fetchSources")
|
||||
|
||||
@@ -23,6 +23,7 @@ final class FetchSourceOperation: ResultOperation<Source>
|
||||
private var source: Source?
|
||||
|
||||
private let session: URLSession
|
||||
private weak var dataTask: URLSessionDataTask?
|
||||
|
||||
private lazy var dateFormatter: ISO8601DateFormatter = {
|
||||
let dateFormatter = ISO8601DateFormatter()
|
||||
@@ -54,6 +55,13 @@ final class FetchSourceOperation: ResultOperation<Source>
|
||||
self.session = URLSession(configuration: configuration)
|
||||
}
|
||||
|
||||
override func cancel()
|
||||
{
|
||||
super.cancel()
|
||||
|
||||
self.dataTask?.cancel()
|
||||
}
|
||||
|
||||
override func main()
|
||||
{
|
||||
super.main()
|
||||
@@ -144,6 +152,8 @@ final class FetchSourceOperation: ResultOperation<Source>
|
||||
self.progress.addChild(dataTask.progress, withPendingUnitCount: 1)
|
||||
|
||||
dataTask.resume()
|
||||
|
||||
self.dataTask = dataTask
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
816
AltStore/Sources/AddSourceViewController.swift
Normal file
816
AltStore/Sources/AddSourceViewController.swift
Normal file
@@ -0,0 +1,816 @@
|
||||
//
|
||||
// AddSourceViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 9/26/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
import AltStoreCore
|
||||
import Roxas
|
||||
|
||||
import Nuke
|
||||
|
||||
private extension UIAction.Identifier
|
||||
{
|
||||
static let addSource = UIAction.Identifier("io.altstore.AddSource")
|
||||
}
|
||||
|
||||
private typealias SourcePreviewResult = (sourceURL: URL, result: Result<Managed<Source>, Error>)
|
||||
|
||||
extension AddSourceViewController
|
||||
{
|
||||
private enum Section: Int
|
||||
{
|
||||
case add
|
||||
case preview
|
||||
case recommended
|
||||
}
|
||||
|
||||
private enum ReuseID: String
|
||||
{
|
||||
case textFieldCell = "TextFieldCell"
|
||||
case placeholderFooter = "PlaceholderFooter"
|
||||
}
|
||||
|
||||
private class ViewModel: ObservableObject
|
||||
{
|
||||
/* Pipeline */
|
||||
@Published
|
||||
var sourceAddress: String = ""
|
||||
|
||||
@Published
|
||||
var sourceURL: URL?
|
||||
|
||||
@Published
|
||||
var sourcePreviewResult: SourcePreviewResult?
|
||||
|
||||
|
||||
/* State */
|
||||
@Published
|
||||
var isLoadingPreview: Bool = false
|
||||
|
||||
@Published
|
||||
var isShowingPreviewStatus: Bool = false
|
||||
}
|
||||
}
|
||||
|
||||
class AddSourceViewController: UICollectionViewController
|
||||
{
|
||||
private lazy var dataSource = self.makeDataSource()
|
||||
private lazy var addSourceDataSource = self.makeAddSourceDataSource()
|
||||
private lazy var sourcePreviewDataSource = self.makeSourcePreviewDataSource()
|
||||
private lazy var recommendedSourcesDataSource = self.makeRecommendedSourcesDataSource()
|
||||
|
||||
private var fetchRecommendedSourcesOperation: UpdateKnownSourcesOperation?
|
||||
private var fetchRecommendedSourcesResult: Result<Void, Error>?
|
||||
private var _fetchRecommendedSourcesContext: NSManagedObjectContext?
|
||||
|
||||
private let viewModel = ViewModel()
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
override func viewDidLoad()
|
||||
{
|
||||
super.viewDidLoad()
|
||||
|
||||
self.navigationController?.isModalInPresentation = true
|
||||
self.navigationController?.view.tintColor = .altPrimary
|
||||
|
||||
let layout = self.makeLayout()
|
||||
self.collectionView.collectionViewLayout = layout
|
||||
|
||||
self.collectionView.register(AppBannerCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||
self.collectionView.register(AddSourceTextFieldCell.self, forCellWithReuseIdentifier: ReuseID.textFieldCell.rawValue)
|
||||
|
||||
self.collectionView.register(UICollectionViewListCell.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: UICollectionView.elementKindSectionHeader)
|
||||
self.collectionView.register(UICollectionViewListCell.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: UICollectionView.elementKindSectionFooter)
|
||||
self.collectionView.register(PlaceholderCollectionReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: ReuseID.placeholderFooter.rawValue)
|
||||
|
||||
self.collectionView.backgroundColor = .altBackground
|
||||
self.collectionView.keyboardDismissMode = .onDrag
|
||||
|
||||
self.collectionView.dataSource = self.dataSource
|
||||
self.collectionView.prefetchDataSource = self.dataSource
|
||||
|
||||
self.startPipeline()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool)
|
||||
{
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
if self.fetchRecommendedSourcesOperation == nil
|
||||
{
|
||||
self.fetchRecommendedSources()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension AddSourceViewController
|
||||
{
|
||||
func makeLayout() -> UICollectionViewCompositionalLayout
|
||||
{
|
||||
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration()
|
||||
layoutConfig.contentInsetsReference = .safeArea
|
||||
|
||||
let layout = UICollectionViewCompositionalLayout(sectionProvider: { [weak self] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
|
||||
guard let self, let section = Section(rawValue: sectionIndex) else { return nil }
|
||||
switch section
|
||||
{
|
||||
case .add:
|
||||
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
|
||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||
|
||||
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
|
||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
|
||||
|
||||
let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(20))
|
||||
let headerItem = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
|
||||
|
||||
let layoutSection = NSCollectionLayoutSection(group: group)
|
||||
layoutSection.interGroupSpacing = 10
|
||||
layoutSection.boundarySupplementaryItems = [headerItem]
|
||||
return layoutSection
|
||||
|
||||
case .preview:
|
||||
var configuration = UICollectionLayoutListConfiguration(appearance: .grouped)
|
||||
configuration.showsSeparators = false
|
||||
configuration.backgroundColor = .clear
|
||||
|
||||
if self.viewModel.sourceURL != nil && self.viewModel.isShowingPreviewStatus
|
||||
{
|
||||
switch self.viewModel.sourcePreviewResult
|
||||
{
|
||||
case (_, .success)?: configuration.footerMode = .none
|
||||
case (_, .failure)?: configuration.footerMode = .supplementary
|
||||
case nil where self.viewModel.isLoadingPreview: configuration.footerMode = .supplementary
|
||||
default: configuration.footerMode = .none
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
configuration.footerMode = .none
|
||||
}
|
||||
|
||||
let layoutSection = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: layoutEnvironment)
|
||||
return layoutSection
|
||||
|
||||
case .recommended:
|
||||
var configuration = UICollectionLayoutListConfiguration(appearance: .grouped)
|
||||
configuration.showsSeparators = false
|
||||
configuration.backgroundColor = .clear
|
||||
|
||||
switch self.fetchRecommendedSourcesResult
|
||||
{
|
||||
case nil:
|
||||
configuration.headerMode = .supplementary
|
||||
configuration.footerMode = .supplementary
|
||||
|
||||
case .failure: configuration.footerMode = .supplementary
|
||||
case .success: configuration.headerMode = .supplementary
|
||||
}
|
||||
|
||||
let layoutSection = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: layoutEnvironment)
|
||||
return layoutSection
|
||||
}
|
||||
}, configuration: layoutConfig)
|
||||
|
||||
return layout
|
||||
}
|
||||
|
||||
func makeDataSource() -> RSTCompositeCollectionViewPrefetchingDataSource<Source, UIImage>
|
||||
{
|
||||
let dataSource = RSTCompositeCollectionViewPrefetchingDataSource<Source, UIImage>(dataSources: [self.addSourceDataSource,
|
||||
self.sourcePreviewDataSource,
|
||||
self.recommendedSourcesDataSource])
|
||||
dataSource.proxy = self
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func makeAddSourceDataSource() -> RSTDynamicCollectionViewPrefetchingDataSource<Source, UIImage>
|
||||
{
|
||||
let dataSource = RSTDynamicCollectionViewPrefetchingDataSource<Source, UIImage>()
|
||||
dataSource.numberOfSectionsHandler = { 1 }
|
||||
dataSource.numberOfItemsHandler = { _ in 1 }
|
||||
dataSource.cellIdentifierHandler = { _ in ReuseID.textFieldCell.rawValue }
|
||||
dataSource.cellConfigurationHandler = { [weak self] cell, source, indexPath in
|
||||
guard let self else { return }
|
||||
|
||||
let cell = cell as! AddSourceTextFieldCell
|
||||
cell.contentView.layoutMargins.left = self.view.layoutMargins.left
|
||||
cell.contentView.layoutMargins.right = self.view.layoutMargins.right
|
||||
|
||||
cell.textField.delegate = self
|
||||
|
||||
cell.setNeedsLayout()
|
||||
cell.layoutIfNeeded()
|
||||
|
||||
NotificationCenter.default
|
||||
.publisher(for: UITextField.textDidChangeNotification, object: cell.textField)
|
||||
.map { ($0.object as? UITextField)?.text ?? "" }
|
||||
.assign(to: &self.viewModel.$sourceAddress)
|
||||
|
||||
// Results in memory leak
|
||||
// .assign(to: \.viewModel.sourceAddress, on: self)
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func makeSourcePreviewDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<Source, UIImage>
|
||||
{
|
||||
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<Source, UIImage>(items: [])
|
||||
dataSource.cellConfigurationHandler = { [weak self] cell, source, indexPath in
|
||||
guard let self else { return }
|
||||
|
||||
let cell = cell as! AppBannerCollectionViewCell
|
||||
self.configure(cell, with: source)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func makeRecommendedSourcesDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<Source, UIImage>
|
||||
{
|
||||
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<Source, UIImage>(items: [])
|
||||
dataSource.cellConfigurationHandler = { [weak self] cell, source, indexPath in
|
||||
guard let self else { return }
|
||||
|
||||
let cell = cell as! AppBannerCollectionViewCell
|
||||
self.configure(cell, with: source)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private extension AddSourceViewController
|
||||
{
|
||||
func startPipeline()
|
||||
{
|
||||
/* Pipeline */
|
||||
|
||||
// Map UITextField text -> URL
|
||||
self.viewModel.$sourceAddress
|
||||
.map { [weak self] in self?.sourceURL(from: $0) }
|
||||
.assign(to: &self.viewModel.$sourceURL)
|
||||
|
||||
let showPreviewStatusPublisher = self.viewModel.$isShowingPreviewStatus
|
||||
.filter { $0 == true }
|
||||
|
||||
let sourceURLPublisher = self.viewModel.$sourceURL
|
||||
.removeDuplicates()
|
||||
.debounce(for: 0.2, scheduler: RunLoop.main)
|
||||
.receive(on: RunLoop.main)
|
||||
.map { [weak self] sourceURL in
|
||||
// Only set sourcePreviewResult to nil if sourceURL actually changes.
|
||||
self?.viewModel.sourcePreviewResult = nil
|
||||
return sourceURL
|
||||
}
|
||||
|
||||
// Map URL -> Source Preview
|
||||
Publishers.CombineLatest(sourceURLPublisher, showPreviewStatusPublisher.prepend(false))
|
||||
.receive(on: RunLoop.main)
|
||||
.map { $0.0 }
|
||||
.compactMap { [weak self] (sourceURL: URL?) -> AnyPublisher<SourcePreviewResult?, Never>? in
|
||||
guard let self else { return nil }
|
||||
|
||||
guard let sourceURL else {
|
||||
// Unlike above guard, this continues the pipeline with nil value.
|
||||
return Just(nil).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
self.viewModel.isLoadingPreview = true
|
||||
return self.fetchSourcePreview(sourceURL: sourceURL).eraseToAnyPublisher()
|
||||
}
|
||||
.switchToLatest() // Cancels previous publisher
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] sourcePreviewResult in
|
||||
self?.viewModel.isLoadingPreview = false
|
||||
self?.viewModel.sourcePreviewResult = sourcePreviewResult
|
||||
}
|
||||
.store(in: &self.cancellables)
|
||||
|
||||
|
||||
/* Update UI */
|
||||
|
||||
Publishers.CombineLatest(self.viewModel.$isLoadingPreview.removeDuplicates(),
|
||||
self.viewModel.$isShowingPreviewStatus.removeDuplicates())
|
||||
.sink { [weak self] _ in
|
||||
guard let self else { return }
|
||||
|
||||
// @Published fires _before_ property is updated, so wait until next run loop.
|
||||
DispatchQueue.main.async {
|
||||
self.collectionView.performBatchUpdates {
|
||||
let indexPath = IndexPath(item: 0, section: Section.preview.rawValue)
|
||||
|
||||
if let footerView = self.collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: indexPath) as? PlaceholderCollectionReusableView
|
||||
{
|
||||
self.configure(footerView, with: self.viewModel.sourcePreviewResult)
|
||||
}
|
||||
|
||||
let context = UICollectionViewLayoutInvalidationContext()
|
||||
context.invalidateSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter, at: [indexPath])
|
||||
self.collectionView.collectionViewLayout.invalidateLayout(with: context)
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &self.cancellables)
|
||||
|
||||
self.viewModel.$sourcePreviewResult
|
||||
.map { $0?.1 }
|
||||
.map { result -> Managed<Source>? in
|
||||
switch result
|
||||
{
|
||||
case .success(let source): return source
|
||||
case .failure, nil: return nil
|
||||
}
|
||||
}
|
||||
.removeDuplicates { (sourceA: Managed<Source>?, sourceB: Managed<Source>?) in
|
||||
sourceA?.identifier == sourceB?.identifier
|
||||
}
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] source in
|
||||
self?.updateSourcePreview(for: source?.wrappedValue)
|
||||
}
|
||||
.store(in: &self.cancellables)
|
||||
|
||||
let addPublisher = NotificationCenter.default.publisher(for: AppManager.didAddSourceNotification)
|
||||
let removePublisher = NotificationCenter.default.publisher(for: AppManager.didRemoveSourceNotification)
|
||||
Publishers.Merge(addPublisher, removePublisher)
|
||||
.compactMap { notification -> String? in
|
||||
guard let source = notification.object as? Source,
|
||||
let context = source.managedObjectContext
|
||||
else { return nil }
|
||||
|
||||
let sourceID = context.performAndWait { source.identifier }
|
||||
return sourceID
|
||||
}
|
||||
.receive(on: RunLoop.main)
|
||||
.compactMap { [dataSource = recommendedSourcesDataSource] sourceID -> IndexPath? in
|
||||
guard let index = dataSource.items.firstIndex(where: { $0.identifier == sourceID }) else { return nil }
|
||||
|
||||
let indexPath = IndexPath(item: index, section: Section.recommended.rawValue)
|
||||
return indexPath
|
||||
}
|
||||
.sink { [weak self] indexPath in
|
||||
// Added or removed a recommended source, so make sure to update its state.
|
||||
self?.collectionView.reloadItems(at: [indexPath])
|
||||
}
|
||||
.store(in: &self.cancellables)
|
||||
}
|
||||
|
||||
func sourceURL(from address: String) -> URL?
|
||||
{
|
||||
guard let sourceURL = URL(string: address) else { return nil }
|
||||
|
||||
// URLs without hosts are OK (e.g. localhost:8000)
|
||||
// guard sourceURL.host != nil else { return }
|
||||
|
||||
guard let scheme = sourceURL.scheme else {
|
||||
let sanitizedURL = URL(string: "https://" + address)
|
||||
return sanitizedURL
|
||||
}
|
||||
|
||||
guard scheme.lowercased() != "localhost" else {
|
||||
let sanitizedURL = URL(string: "http://" + address)
|
||||
return sanitizedURL
|
||||
}
|
||||
|
||||
return sourceURL
|
||||
}
|
||||
|
||||
func fetchSourcePreview(sourceURL: URL) -> some Publisher<SourcePreviewResult?, Never>
|
||||
{
|
||||
let context = DatabaseManager.shared.persistentContainer.newBackgroundSavingViewContext()
|
||||
|
||||
var fetchOperation: FetchSourceOperation?
|
||||
return Future<Source, Error> { promise in
|
||||
fetchOperation = AppManager.shared.fetchSource(sourceURL: sourceURL, managedObjectContext: context) { result in
|
||||
promise(result)
|
||||
}
|
||||
}
|
||||
.map { source in
|
||||
let result = SourcePreviewResult(sourceURL, .success(Managed(wrappedValue: source)))
|
||||
return result
|
||||
}
|
||||
.catch { error in
|
||||
print("Failed to fetch source for URL \(sourceURL).", error.localizedDescription)
|
||||
|
||||
let result = SourcePreviewResult(sourceURL, .failure(error))
|
||||
return Just<SourcePreviewResult?>(result)
|
||||
}
|
||||
.handleEvents(receiveCancel: {
|
||||
fetchOperation?.cancel()
|
||||
})
|
||||
}
|
||||
|
||||
func updateSourcePreview(for source: Source?)
|
||||
{
|
||||
let items = [source].compactMap { $0 }
|
||||
|
||||
// Have to provide changes in terms of sourcePreviewDataSource.
|
||||
let indexPath = IndexPath(row: 0, section: 0)
|
||||
|
||||
if !items.isEmpty && self.sourcePreviewDataSource.items.isEmpty
|
||||
{
|
||||
let change = RSTCellContentChange(type: .insert, currentIndexPath: nil, destinationIndexPath: indexPath)
|
||||
self.sourcePreviewDataSource.setItems(items, with: [change])
|
||||
}
|
||||
else if items.isEmpty && !self.sourcePreviewDataSource.items.isEmpty
|
||||
{
|
||||
let change = RSTCellContentChange(type: .delete, currentIndexPath: indexPath, destinationIndexPath: nil)
|
||||
self.sourcePreviewDataSource.setItems(items, with: [change])
|
||||
}
|
||||
else if !items.isEmpty && !self.sourcePreviewDataSource.items.isEmpty
|
||||
{
|
||||
let change = RSTCellContentChange(type: .update, currentIndexPath: indexPath, destinationIndexPath: indexPath)
|
||||
self.sourcePreviewDataSource.setItems(items, with: [change])
|
||||
}
|
||||
|
||||
if source == nil
|
||||
{
|
||||
self.collectionView.reloadSections([Section.preview.rawValue])
|
||||
}
|
||||
else
|
||||
{
|
||||
self.collectionView.collectionViewLayout.invalidateLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension AddSourceViewController
|
||||
{
|
||||
func configure(_ cell: AppBannerCollectionViewCell, with source: Source)
|
||||
{
|
||||
cell.bannerView.style = .source
|
||||
cell.layoutMargins.top = 5
|
||||
cell.layoutMargins.bottom = 5
|
||||
cell.layoutMargins.left = self.view.layoutMargins.left
|
||||
cell.layoutMargins.right = self.view.layoutMargins.right
|
||||
cell.contentView.backgroundColor = .altBackground
|
||||
|
||||
cell.bannerView.configure(for: source)
|
||||
cell.bannerView.subtitleLabel.numberOfLines = 2
|
||||
|
||||
cell.bannerView.iconImageView.image = nil
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = true
|
||||
|
||||
let config = UIImage.SymbolConfiguration(scale: .medium)
|
||||
let image = UIImage(systemName: "plus.circle.fill", withConfiguration: config)?.withTintColor(.white, renderingMode: .alwaysOriginal)
|
||||
cell.bannerView.button.setImage(image, for: .normal)
|
||||
cell.bannerView.button.setImage(image, for: .highlighted)
|
||||
cell.bannerView.button.setTitle(nil, for: .normal)
|
||||
cell.bannerView.button.imageView?.contentMode = .scaleAspectFit
|
||||
cell.bannerView.button.contentHorizontalAlignment = .fill // Fill entire button with imageView
|
||||
cell.bannerView.button.contentVerticalAlignment = .fill
|
||||
cell.bannerView.button.contentEdgeInsets = .zero
|
||||
cell.bannerView.button.tintColor = .clear
|
||||
cell.bannerView.button.isHidden = false
|
||||
|
||||
let action = UIAction(identifier: .addSource) { [weak self] _ in
|
||||
self?.add(source)
|
||||
}
|
||||
cell.bannerView.button.addAction(action, for: .primaryActionTriggered)
|
||||
|
||||
Task<Void, Never>(priority: .userInitiated) {
|
||||
do
|
||||
{
|
||||
let isAdded = try await source.isAdded
|
||||
if isAdded
|
||||
{
|
||||
cell.bannerView.button.isHidden = true
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
print("Failed to determine if source is added.", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func configure(_ footerView: PlaceholderCollectionReusableView, with sourcePreviewResult: SourcePreviewResult?)
|
||||
{
|
||||
footerView.placeholderView.stackView.isLayoutMarginsRelativeArrangement = false
|
||||
|
||||
footerView.placeholderView.textLabel.textColor = .secondaryLabel
|
||||
footerView.placeholderView.textLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||
footerView.placeholderView.textLabel.textAlignment = .center
|
||||
|
||||
footerView.placeholderView.detailTextLabel.isHidden = true
|
||||
|
||||
switch sourcePreviewResult
|
||||
{
|
||||
case (let sourceURL, .failure(let previewError))? where self.viewModel.sourceURL == sourceURL && !self.viewModel.isLoadingPreview:
|
||||
// The current URL matches the error being displayed, and we're not loading another preview, so show error.
|
||||
|
||||
footerView.placeholderView.textLabel.text = (previewError as NSError).localizedDebugDescription ?? previewError.localizedDescription
|
||||
footerView.placeholderView.textLabel.isHidden = false
|
||||
|
||||
footerView.placeholderView.activityIndicatorView.stopAnimating()
|
||||
|
||||
default:
|
||||
// The current URL does not match the URL of the source/error being displayed, so show loading indicator.
|
||||
|
||||
footerView.placeholderView.textLabel.text = nil
|
||||
footerView.placeholderView.textLabel.isHidden = true
|
||||
|
||||
footerView.placeholderView.activityIndicatorView.startAnimating()
|
||||
}
|
||||
}
|
||||
|
||||
func fetchRecommendedSources()
|
||||
{
|
||||
// Closure instead of local function so we can capture `self` weakly.
|
||||
let finish: (Result<[Source], Error>) -> Void = { [weak self] result in
|
||||
self?.fetchRecommendedSourcesResult = result.map { _ in () }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
do
|
||||
{
|
||||
let sources = try result.get()
|
||||
print("Fetched recommended sources:", sources.map { $0.identifier })
|
||||
|
||||
let sectionUpdate = RSTCellContentChange(type: .update, sectionIndex: 0)
|
||||
self?.recommendedSourcesDataSource.setItems(sources, with: [sectionUpdate])
|
||||
}
|
||||
catch
|
||||
{
|
||||
print("Error fetching recommended sources:", error)
|
||||
|
||||
let sectionUpdate = RSTCellContentChange(type: .update, sectionIndex: 0)
|
||||
self?.recommendedSourcesDataSource.setItems([], with: [sectionUpdate])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.fetchRecommendedSourcesOperation = AppManager.shared.updateKnownSources { [weak self] result in
|
||||
switch result
|
||||
{
|
||||
case .failure(let error): finish(.failure(error))
|
||||
case .success((let trustedSources, _)):
|
||||
|
||||
// Don't show sources without a sourceURL.
|
||||
let featuredSourceURLs = trustedSources.compactMap { $0.sourceURL }
|
||||
|
||||
// This context is never saved, but keeps the managed sources alive.
|
||||
let context = DatabaseManager.shared.persistentContainer.newBackgroundSavingViewContext()
|
||||
self?._fetchRecommendedSourcesContext = context
|
||||
|
||||
let dispatchGroup = DispatchGroup()
|
||||
|
||||
var sourcesByURL = [URL: Source]()
|
||||
var fetchError: Error?
|
||||
|
||||
for sourceURL in featuredSourceURLs
|
||||
{
|
||||
dispatchGroup.enter()
|
||||
|
||||
AppManager.shared.fetchSource(sourceURL: sourceURL, managedObjectContext: context) { result in
|
||||
// Serialize access to sourcesByURL.
|
||||
context.performAndWait {
|
||||
switch result
|
||||
{
|
||||
case .failure(let error):
|
||||
print("Failed to load recommended source \(sourceURL.absoluteString):", error.localizedDescription)
|
||||
fetchError = error
|
||||
|
||||
case .success(let source): sourcesByURL[source.sourceURL] = source
|
||||
}
|
||||
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispatchGroup.notify(queue: .main) {
|
||||
let sources = featuredSourceURLs.compactMap { sourcesByURL[$0] }
|
||||
|
||||
if let error = fetchError, sources.isEmpty
|
||||
{
|
||||
finish(.failure(error))
|
||||
}
|
||||
else
|
||||
{
|
||||
finish(.success(sources))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func add(@AsyncManaged _ source: Source)
|
||||
{
|
||||
Task<Void, Never> {
|
||||
do
|
||||
{
|
||||
let isRecommended = await $source.isRecommended
|
||||
if isRecommended
|
||||
{
|
||||
try await AppManager.shared.add(source, message: nil, presentingViewController: self)
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use default message
|
||||
try await AppManager.shared.add(source, presentingViewController: self)
|
||||
}
|
||||
|
||||
self.dismiss()
|
||||
|
||||
}
|
||||
catch is CancellationError {}
|
||||
catch
|
||||
{
|
||||
let errorTitle = NSLocalizedString("Unable to Add Source", comment: "")
|
||||
await self.presentAlert(title: errorTitle, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dismiss()
|
||||
{
|
||||
guard
|
||||
let navigationController = self.navigationController, let presentingViewController = navigationController.presentingViewController
|
||||
else { return }
|
||||
|
||||
presentingViewController.dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private extension AddSourceViewController
|
||||
{
|
||||
@IBSegueAction
|
||||
func makeSourceDetailViewController(_ coder: NSCoder, sender: Any?) -> UIViewController?
|
||||
{
|
||||
guard let source = sender as? Source else { return nil }
|
||||
|
||||
let sourceDetailViewController = SourceDetailViewController(source: source, coder: coder)
|
||||
sourceDetailViewController?.addedSourceHandler = { [weak self] _ in
|
||||
self?.dismiss()
|
||||
}
|
||||
return sourceDetailViewController
|
||||
}
|
||||
}
|
||||
|
||||
extension AddSourceViewController
|
||||
{
|
||||
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
||||
{
|
||||
guard Section(rawValue: indexPath.section) != .add else { return }
|
||||
|
||||
let source = self.dataSource.item(at: indexPath)
|
||||
self.performSegue(withIdentifier: "showSourceDetails", sender: source)
|
||||
}
|
||||
}
|
||||
|
||||
extension AddSourceViewController: UICollectionViewDelegateFlowLayout
|
||||
{
|
||||
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
|
||||
{
|
||||
let section = Section(rawValue: indexPath.section)!
|
||||
switch (section, kind)
|
||||
{
|
||||
case (.add, UICollectionView.elementKindSectionHeader):
|
||||
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! UICollectionViewListCell
|
||||
|
||||
var configuation = UIListContentConfiguration.cell()
|
||||
configuation.text = NSLocalizedString("Enter a source's URL below, or add one of the recommended sources.", comment: "")
|
||||
configuation.textProperties.color = .secondaryLabel
|
||||
|
||||
headerView.contentConfiguration = configuation
|
||||
|
||||
return headerView
|
||||
|
||||
case (.preview, UICollectionView.elementKindSectionFooter):
|
||||
let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ReuseID.placeholderFooter.rawValue, for: indexPath) as! PlaceholderCollectionReusableView
|
||||
|
||||
self.configure(footerView, with: self.viewModel.sourcePreviewResult)
|
||||
|
||||
return footerView
|
||||
|
||||
case (.recommended, UICollectionView.elementKindSectionHeader):
|
||||
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! UICollectionViewListCell
|
||||
|
||||
var configuation = UIListContentConfiguration.groupedHeader()
|
||||
configuation.text = NSLocalizedString("Recommended Sources", comment: "")
|
||||
configuation.textProperties.color = .secondaryLabel
|
||||
|
||||
headerView.contentConfiguration = configuation
|
||||
|
||||
return headerView
|
||||
|
||||
case (.recommended, UICollectionView.elementKindSectionFooter):
|
||||
let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ReuseID.placeholderFooter.rawValue, for: indexPath) as! PlaceholderCollectionReusableView
|
||||
|
||||
footerView.placeholderView.stackView.spacing = 15
|
||||
footerView.placeholderView.stackView.directionalLayoutMargins.top = 20
|
||||
footerView.placeholderView.stackView.isLayoutMarginsRelativeArrangement = true
|
||||
|
||||
if let result = self.fetchRecommendedSourcesResult, case .failure(let error) = result
|
||||
{
|
||||
footerView.placeholderView.textLabel.isHidden = false
|
||||
footerView.placeholderView.textLabel.font = UIFont.preferredFont(forTextStyle: .headline)
|
||||
footerView.placeholderView.textLabel.text = NSLocalizedString("Unable to Load Recommended Sources", comment: "")
|
||||
|
||||
footerView.placeholderView.detailTextLabel.isHidden = false
|
||||
footerView.placeholderView.detailTextLabel.text = error.localizedDescription
|
||||
|
||||
footerView.placeholderView.activityIndicatorView.stopAnimating()
|
||||
}
|
||||
else
|
||||
{
|
||||
footerView.placeholderView.textLabel.isHidden = true
|
||||
footerView.placeholderView.detailTextLabel.isHidden = true
|
||||
|
||||
footerView.placeholderView.activityIndicatorView.startAnimating()
|
||||
}
|
||||
|
||||
return footerView
|
||||
|
||||
default: fatalError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AddSourceViewController: UITextFieldDelegate
|
||||
{
|
||||
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool
|
||||
{
|
||||
self.viewModel.isShowingPreviewStatus = false
|
||||
return true
|
||||
}
|
||||
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool
|
||||
{
|
||||
textField.resignFirstResponder()
|
||||
return false
|
||||
}
|
||||
|
||||
func textFieldDidEndEditing(_ textField: UITextField)
|
||||
{
|
||||
self.viewModel.isShowingPreviewStatus = true
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
#Preview(traits: .portrait) {
|
||||
DatabaseManager.shared.startForPreview()
|
||||
|
||||
let storyboard = UIStoryboard(name: "Sources", bundle: .main)
|
||||
|
||||
let addSourceNavigationController = storyboard.instantiateViewController(withIdentifier: "addSourceNavigationController")
|
||||
return addSourceNavigationController
|
||||
}
|
||||
93
AltStore/Sources/Components/AddSourceTextFieldCell.swift
Normal file
93
AltStore/Sources/Components/AddSourceTextFieldCell.swift
Normal file
@@ -0,0 +1,93 @@
|
||||
//
|
||||
// AddSourceTextFieldCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 10/17/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class AddSourceTextFieldCell: UICollectionViewCell
|
||||
{
|
||||
let textField: UITextField
|
||||
|
||||
private let backgroundEffectView: UIVisualEffectView
|
||||
private let imageView: UIImageView
|
||||
|
||||
override init(frame: CGRect)
|
||||
{
|
||||
self.textField = UITextField(frame: frame)
|
||||
self.textField.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.textField.placeholder = "apps.altstore.io"
|
||||
self.textField.textContentType = .URL
|
||||
self.textField.keyboardType = .URL
|
||||
self.textField.returnKeyType = .done
|
||||
self.textField.autocapitalizationType = .none
|
||||
self.textField.autocorrectionType = .no
|
||||
self.textField.spellCheckingType = .no
|
||||
self.textField.enablesReturnKeyAutomatically = true
|
||||
self.textField.tintColor = .altPrimary
|
||||
self.textField.textColor = UIColor { traits in
|
||||
if traits.userInterfaceStyle == .dark
|
||||
{
|
||||
//TODO: Change once we update UIColor.altPrimary to match 2.0 icon.
|
||||
return UIColor(resource: .gradientTop)
|
||||
}
|
||||
else
|
||||
{
|
||||
return UIColor.altPrimary
|
||||
}
|
||||
}
|
||||
|
||||
let blurEffect = UIBlurEffect(style: .systemChromeMaterial)
|
||||
self.backgroundEffectView = UIVisualEffectView(effect: blurEffect)
|
||||
self.backgroundEffectView.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.backgroundEffectView.clipsToBounds = true
|
||||
self.backgroundEffectView.backgroundColor = .altPrimary
|
||||
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 20, weight: .bold)
|
||||
let image = UIImage(systemName: "link", withConfiguration: config)?.withRenderingMode(.alwaysTemplate)
|
||||
self.imageView = UIImageView(image: image)
|
||||
self.imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.imageView.contentMode = .center
|
||||
self.imageView.tintColor = .altPrimary
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
self.contentView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
self.backgroundEffectView.contentView.addSubview(self.imageView)
|
||||
self.backgroundEffectView.contentView.addSubview(self.textField)
|
||||
self.contentView.addSubview(self.backgroundEffectView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
self.backgroundEffectView.leadingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.leadingAnchor),
|
||||
self.backgroundEffectView.trailingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.trailingAnchor),
|
||||
self.backgroundEffectView.topAnchor.constraint(equalTo: self.contentView.topAnchor),
|
||||
self.backgroundEffectView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor),
|
||||
|
||||
self.imageView.widthAnchor.constraint(equalToConstant: 44),
|
||||
self.imageView.heightAnchor.constraint(equalToConstant: 44),
|
||||
self.imageView.centerYAnchor.constraint(equalTo: self.backgroundEffectView.centerYAnchor),
|
||||
|
||||
self.textField.topAnchor.constraint(equalTo: self.backgroundEffectView.topAnchor, constant: 15),
|
||||
self.textField.bottomAnchor.constraint(equalTo: self.backgroundEffectView.bottomAnchor, constant: -15),
|
||||
self.textField.trailingAnchor.constraint(equalTo: self.backgroundEffectView.trailingAnchor, constant: -15),
|
||||
|
||||
self.imageView.leadingAnchor.constraint(equalTo: self.backgroundEffectView.leadingAnchor, constant: 15),
|
||||
self.textField.leadingAnchor.constraint(equalToSystemSpacingAfter: self.imageView.trailingAnchor, multiplier: 1.0),
|
||||
])
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews()
|
||||
{
|
||||
super.layoutSubviews()
|
||||
|
||||
self.backgroundEffectView.layer.cornerRadius = self.backgroundEffectView.bounds.midY
|
||||
}
|
||||
}
|
||||
@@ -87,3 +87,27 @@ class TextViewCollectionViewCell: UICollectionViewCell
|
||||
self.textView.textContainerInset.right = self.contentView.layoutMargins.right
|
||||
}
|
||||
}
|
||||
|
||||
class PlaceholderCollectionReusableView: UICollectionReusableView
|
||||
{
|
||||
let placeholderView: RSTPlaceholderView
|
||||
|
||||
override init(frame: CGRect)
|
||||
{
|
||||
self.placeholderView = RSTPlaceholderView(frame: .zero)
|
||||
self.placeholderView.activityIndicatorView.style = .medium
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
self.addSubview(self.placeholderView, pinningEdgesWith: .zero)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
self.placeholderView.stackView.topAnchor.constraint(equalTo: self.placeholderView.topAnchor),
|
||||
self.placeholderView.stackView.bottomAnchor.constraint(equalTo: self.placeholderView.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,30 @@ extension SourceDetailViewController
|
||||
{
|
||||
self.source = source
|
||||
|
||||
let sourceID = source.identifier
|
||||
|
||||
let addedPublisher = NotificationCenter.default.publisher(for: AppManager.didAddSourceNotification, object: nil)
|
||||
let removedPublisher = NotificationCenter.default.publisher(for: AppManager.didRemoveSourceNotification, object: nil)
|
||||
|
||||
Publishers.Merge(addedPublisher, removedPublisher)
|
||||
.filter { notification -> Bool in
|
||||
guard let source = notification.object as? Source, let context = source.managedObjectContext else { return false }
|
||||
|
||||
let updatedSourceID = context.performAndWait { source.identifier }
|
||||
return sourceID == updatedSourceID
|
||||
}
|
||||
.compactMap { notification in
|
||||
switch notification.name
|
||||
{
|
||||
case AppManager.didAddSourceNotification: return true
|
||||
case AppManager.didRemoveSourceNotification: return false
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
.filter { $0 != nil }
|
||||
.receive(on: RunLoop.main)
|
||||
.assign(to: &self.$isSourceAdded)
|
||||
|
||||
Task<Void, Never> {
|
||||
do
|
||||
{
|
||||
@@ -49,6 +73,8 @@ class SourceDetailViewController: HeaderContentViewController<SourceHeaderView,
|
||||
{
|
||||
@Managed private(set) var source: Source
|
||||
|
||||
var addedSourceHandler: ((Source) -> Void)?
|
||||
|
||||
private let viewModel: ViewModel
|
||||
|
||||
private var addButton: VibrantButton!
|
||||
@@ -224,7 +250,6 @@ class SourceDetailViewController: HeaderContentViewController<SourceHeaderView,
|
||||
self.viewModel.isAddingSource = true
|
||||
|
||||
Task<Void, Never> { /* @MainActor in */ // Already on MainActor, even though this function wasn't called from async context.
|
||||
var isSourceAdded: Bool?
|
||||
var errorTitle = NSLocalizedString("Unable to Add Source", comment: "")
|
||||
|
||||
do
|
||||
@@ -238,9 +263,9 @@ class SourceDetailViewController: HeaderContentViewController<SourceHeaderView,
|
||||
else
|
||||
{
|
||||
try await AppManager.shared.add(self.source, presentingViewController: self)
|
||||
|
||||
self.addedSourceHandler?(self.source)
|
||||
}
|
||||
|
||||
isSourceAdded = try await self.source.isAdded
|
||||
}
|
||||
catch is CancellationError {}
|
||||
catch
|
||||
@@ -249,11 +274,6 @@ class SourceDetailViewController: HeaderContentViewController<SourceHeaderView,
|
||||
}
|
||||
|
||||
self.viewModel.isAddingSource = false
|
||||
|
||||
if let isSourceAdded
|
||||
{
|
||||
self.viewModel.isSourceAdded = isSourceAdded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="22154" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="7We-99-yEv">
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="22155" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="7We-99-yEv">
|
||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22130"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22131"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="collection view cell content view" minToolsVersion="11.0"/>
|
||||
@@ -109,6 +109,7 @@
|
||||
<outlet property="delegate" destination="MSh-hM-32I" id="8SG-5v-iF2"/>
|
||||
</connections>
|
||||
</collectionView>
|
||||
<navigationItem key="navigationItem" id="SsY-RM-EFx"/>
|
||||
<connections>
|
||||
<segue destination="MVH-oB-c8m" kind="show" identifier="showAllNews" destinationCreationSelector="makeNewsViewController:" id="txA-ay-P7p"/>
|
||||
<segue destination="Nhf-Gw-Ukx" kind="show" identifier="showAllApps" destinationCreationSelector="makeBrowseViewController:" id="On0-GP-kaE"/>
|
||||
@@ -174,7 +175,11 @@
|
||||
</connections>
|
||||
</collectionView>
|
||||
<navigationItem key="navigationItem" title="Sources" largeTitleDisplayMode="always" id="Noh-fc-wch">
|
||||
<barButtonItem key="leftBarButtonItem" systemItem="add" id="y96-Ve-1gW"/>
|
||||
<barButtonItem key="leftBarButtonItem" systemItem="add" id="y96-Ve-1gW">
|
||||
<connections>
|
||||
<segue destination="i15-Jk-F75" kind="presentation" identifier="addSource" id="FGe-k6-H7c"/>
|
||||
</connections>
|
||||
</barButtonItem>
|
||||
</navigationItem>
|
||||
<connections>
|
||||
<segue destination="7XE-Wv-lf9" kind="show" identifier="showSourceDetails" destinationCreationSelector="makeSourceDetailViewController:sender:" id="eVl-NI-lj3"/>
|
||||
@@ -184,7 +189,75 @@
|
||||
</objects>
|
||||
<point key="canvasLocation" x="810" y="-13"/>
|
||||
</scene>
|
||||
<!--Add Source-->
|
||||
<scene sceneID="oCv-by-94o">
|
||||
<objects>
|
||||
<collectionViewController id="bbz-wy-kaK" customClass="AddSourceViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" dataMode="prototypes" id="lXl-N1-6cT">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="842"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<collectionViewFlowLayout key="collectionViewLayout" automaticEstimatedItemSize="YES" minimumLineSpacing="10" minimumInteritemSpacing="10" id="Xgg-0U-PlW">
|
||||
<size key="itemSize" width="128" height="128"/>
|
||||
<size key="headerReferenceSize" width="0.0" height="0.0"/>
|
||||
<size key="footerReferenceSize" width="0.0" height="0.0"/>
|
||||
<inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
|
||||
</collectionViewFlowLayout>
|
||||
<cells>
|
||||
<collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" reuseIdentifier="" id="W0l-zW-MjJ">
|
||||
<rect key="frame" x="0.0" y="0.0" width="128" height="128"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
|
||||
<rect key="frame" x="0.0" y="0.0" width="128" height="128"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
</view>
|
||||
</collectionViewCell>
|
||||
</cells>
|
||||
<connections>
|
||||
<outlet property="dataSource" destination="bbz-wy-kaK" id="Fcm-4b-KZC"/>
|
||||
<outlet property="delegate" destination="bbz-wy-kaK" id="RRO-hx-gbl"/>
|
||||
</connections>
|
||||
</collectionView>
|
||||
<navigationItem key="navigationItem" title="Add Source" id="v46-dC-g7e">
|
||||
<barButtonItem key="leftBarButtonItem" systemItem="cancel" id="ozl-rj-JhC">
|
||||
<connections>
|
||||
<segue destination="qr8-ss-Ghz" kind="unwind" unwindAction="unwindFromAddSource:" id="Pba-Kh-qfH"/>
|
||||
</connections>
|
||||
</barButtonItem>
|
||||
</navigationItem>
|
||||
<connections>
|
||||
<segue destination="qr8-ss-Ghz" kind="unwind" unwindAction="unwindFromAddSource:" id="nj3-eB-DML"/>
|
||||
<segue destination="7XE-Wv-lf9" kind="show" identifier="showSourceDetails" destinationCreationSelector="makeSourceDetailViewController:sender:" id="LO9-iP-zZC"/>
|
||||
</connections>
|
||||
</collectionViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="RWS-LV-vih" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
<exit id="qr8-ss-Ghz" userLabel="Exit" sceneMemberID="exit"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="1647" y="-711"/>
|
||||
</scene>
|
||||
<!--Forwarding Navigation Controller-->
|
||||
<scene sceneID="NAl-mP-f8p">
|
||||
<objects>
|
||||
<navigationController storyboardIdentifier="addSourceNavigationController" automaticallyAdjustsScrollViewInsets="NO" id="i15-Jk-F75" customClass="ForwardingNavigationController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<toolbarItems/>
|
||||
<navigationItem key="navigationItem" id="Opx-Pr-Tyy"/>
|
||||
<navigationBar key="navigationBar" contentMode="scaleToFill" largeTitles="YES" id="HLe-3g-P8I" customClass="NavigationBar" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="108"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
</navigationBar>
|
||||
<nil name="viewControllers"/>
|
||||
<connections>
|
||||
<segue destination="bbz-wy-kaK" kind="relationship" relationship="rootViewController" id="uVn-Xo-Kjy"/>
|
||||
</connections>
|
||||
</navigationController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="WTH-Dd-NJP" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="810" y="-711"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<inferredMetricsTieBreakers>
|
||||
<segue reference="LO9-iP-zZC"/>
|
||||
</inferredMetricsTieBreakers>
|
||||
<resources>
|
||||
<image name="Sources" width="20" height="20"/>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
|
||||
@@ -347,6 +347,11 @@ private extension SourcesViewController
|
||||
let sourceDetailViewController = SourceDetailViewController(source: source, coder: coder)
|
||||
return sourceDetailViewController
|
||||
}
|
||||
|
||||
@IBAction
|
||||
func unwindFromAddSource(_ segue: UIStoryboardSegue)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private extension SourcesViewController
|
||||
|
||||
Reference in New Issue
Block a user