- Feature: Implement Bulk add for Sources

This commit is contained in:
Magesh K
2025-02-21 19:06:13 +05:30
parent 15f4ae7b5a
commit 6370105c85
2 changed files with 294 additions and 132 deletions

View File

@@ -43,10 +43,10 @@ extension AddSourceViewController
var sourceAddress: String = "" var sourceAddress: String = ""
@Published @Published
var sourceURL: URL? var sourceURLs: [URL] = []
@Published @Published
var sourcePreviewResult: SourcePreviewResult? var sourcePreviewResults: [SourcePreviewResult] = []
/* State */ /* State */
@@ -60,6 +60,8 @@ extension AddSourceViewController
class AddSourceViewController: UICollectionViewController class AddSourceViewController: UICollectionViewController
{ {
private var stagedForAdd: [Source: Bool] = [:]
private lazy var dataSource = self.makeDataSource() private lazy var dataSource = self.makeDataSource()
private lazy var addSourceDataSource = self.makeAddSourceDataSource() private lazy var addSourceDataSource = self.makeAddSourceDataSource()
private lazy var sourcePreviewDataSource = self.makeSourcePreviewDataSource() private lazy var sourcePreviewDataSource = self.makeSourcePreviewDataSource()
@@ -117,6 +119,7 @@ private extension AddSourceViewController
layoutConfig.contentInsetsReference = .safeArea layoutConfig.contentInsetsReference = .safeArea
let layout = UICollectionViewCompositionalLayout(sectionProvider: { [weak self] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in let layout = UICollectionViewCompositionalLayout(sectionProvider: { [weak self] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
guard let self, let section = Section(rawValue: sectionIndex) else { return nil } guard let self, let section = Section(rawValue: sectionIndex) else { return nil }
switch section switch section
{ {
@@ -140,14 +143,19 @@ private extension AddSourceViewController
configuration.showsSeparators = false configuration.showsSeparators = false
configuration.backgroundColor = .clear configuration.backgroundColor = .clear
if self.viewModel.sourceURL != nil && self.viewModel.isShowingPreviewStatus if !self.viewModel.sourceURLs.isEmpty && self.viewModel.isShowingPreviewStatus
{ {
switch self.viewModel.sourcePreviewResult for result in self.viewModel.sourcePreviewResults
{ {
case (_, .success)?: configuration.footerMode = .none switch result
case (_, .failure)?: configuration.footerMode = .supplementary {
case nil where self.viewModel.isLoadingPreview: configuration.footerMode = .supplementary case (_, .success): configuration.footerMode = .none
default: configuration.footerMode = .none case (_, .failure): configuration.footerMode = .supplementary
break
// case nil where self.viewModel.isLoadingPreview: configuration.footerMode = .supplementary
// break
// default: configuration.footerMode = .none
}
} }
} }
else else
@@ -303,50 +311,58 @@ private extension AddSourceViewController
{ {
/* Pipeline */ /* Pipeline */
// Map UITextField text -> URL // Map UITextField text -> URLs
self.viewModel.$sourceAddress self.viewModel.$sourceAddress
.map { [weak self] in self?.sourceURL(from: $0) } .map { [weak self] in
.assign(to: &self.viewModel.$sourceURL) guard let self else { return [] }
print("\n\nStarting pipeline processing...\n\n")
let lines = $0.split(whereSeparator: { $0.isWhitespace }).map(String.init).compactMap(self.sourceURL)
return lines
}
.assign(to: &self.viewModel.$sourceURLs)
let showPreviewStatusPublisher = self.viewModel.$isShowingPreviewStatus let showPreviewStatusPublisher = self.viewModel.$isShowingPreviewStatus
.filter { $0 == true } .filter { $0 == true }
let sourceURLPublisher = self.viewModel.$sourceURL let sourceURLsPublisher = self.viewModel.$sourceURLs
.removeDuplicates() .removeDuplicates()
.debounce(for: 0.2, scheduler: RunLoop.main) .debounce(for: 0.2, scheduler: RunLoop.main)
.receive(on: RunLoop.main) .receive(on: RunLoop.main)
.map { [weak self] sourceURL in .map { [weak self] sourceURLs in
// Only set sourcePreviewResult to nil if sourceURL actually changes. // Only set sourcePreviewResult to nil if sourceURL actually changes.
self?.viewModel.sourcePreviewResult = nil self?.viewModel.sourcePreviewResults = []
return sourceURL return sourceURLs
} }
// Map URL -> Source Preview // Map URL -> Source Preview
Publishers.CombineLatest(sourceURLPublisher, showPreviewStatusPublisher.prepend(false)) Publishers.CombineLatest(sourceURLsPublisher, showPreviewStatusPublisher.prepend(false))
.receive(on: RunLoop.main) .receive(on: RunLoop.main)
.map { $0.0 } .map { $0.0 }
.compactMap { [weak self] (sourceURL: URL?) -> AnyPublisher<SourcePreviewResult?, Never>? in .flatMap { [weak self] (sourceURLs: [URL]) -> AnyPublisher<[SourcePreviewResult?], Never> in
guard let self else { return nil } guard let self else { return Just([]).eraseToAnyPublisher() }
guard let sourceURL else {
// Unlike above guard, this continues the pipeline with nil value.
return Just(nil).eraseToAnyPublisher()
}
self.viewModel.isLoadingPreview = true self.viewModel.isLoadingPreview = true
return self.fetchSourcePreview(sourceURL: sourceURL).eraseToAnyPublisher()
let publishers = sourceURLs.map { sourceURL in
print("Creating preview for source:", sourceURL, " ...")
return self.fetchSourcePreview(sourceURL: sourceURL)
.eraseToAnyPublisher()
}
return publishers.isEmpty
? Just([]).eraseToAnyPublisher()
: Publishers.MergeMany(publishers)
.collect()
.eraseToAnyPublisher()
} }
.switchToLatest() // Cancels previous publisher .sink { [weak self] sourcePreviewResults in
.receive(on: RunLoop.main)
.sink { [weak self] sourcePreviewResult in
self?.viewModel.isLoadingPreview = false self?.viewModel.isLoadingPreview = false
self?.viewModel.sourcePreviewResult = sourcePreviewResult self?.viewModel.sourcePreviewResults = sourcePreviewResults.compactMap{$0}
} }
.store(in: &self.cancellables) .store(in: &self.cancellables)
/* Update UI */ /* Update UI */
Publishers.CombineLatest(self.viewModel.$isLoadingPreview.removeDuplicates(), Publishers.CombineLatest(self.viewModel.$isLoadingPreview.removeDuplicates(),
self.viewModel.$isShowingPreviewStatus.removeDuplicates()) self.viewModel.$isShowingPreviewStatus.removeDuplicates())
.sink { [weak self] _ in .sink { [weak self] _ in
@@ -359,7 +375,7 @@ private extension AddSourceViewController
if let footerView = self.collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: indexPath) as? PlaceholderCollectionReusableView if let footerView = self.collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: indexPath) as? PlaceholderCollectionReusableView
{ {
self.configure(footerView, with: self.viewModel.sourcePreviewResult) self.configure(footerView, with: self.viewModel.sourcePreviewResults)
} }
let context = UICollectionViewLayoutInvalidationContext() let context = UICollectionViewLayoutInvalidationContext()
@@ -370,27 +386,67 @@ private extension AddSourceViewController
} }
.store(in: &self.cancellables) .store(in: &self.cancellables)
self.viewModel.$sourcePreviewResult // self.viewModel.$sourcePreviewResults
.map { $0?.1 } // .map { sourcePreviewResults -> [Source] in
.map { result -> Managed<Source>? in // // Process the full array:
switch result // // - For each tuple, extract the `result` (the second element)
{ // // - For each result, convert it to a Managed<Source> if it's successful
case .success(let source): return source // // - Remove any nil values from failed results
case .failure, nil: return nil // let managedSources = sourcePreviewResults.compactMap { previewResult -> Managed<Source>? in
// switch previewResult.result {
// case .success(let source):
// return source
// case .failure:
// return nil
// }
// }
// // Optionally, remove duplicates based on identifier:
// // (This groups by identifier and keeps the first occurrence.)
// let uniqueManagedSources = Dictionary(grouping: managedSources, by: { $0.identifier })
// .compactMap { $0.value.first }
//
// // Unwrap Managed<Source> into Source (assuming Managed<Source> has a wrappedValue property)
// let sources = uniqueManagedSources.map { $0.wrappedValue }
// return sources
// }
// .receive(on: RunLoop.main)
// .sink { [weak self] sources in
// self?.updateSourcesPreview(for: sources)
// }
// .store(in: &self.cancellables)
self.viewModel.$sourcePreviewResults
.map { sourcePreviewResults -> [Source] in
var seenIdentifiers = Set<String>()
let orderedSources = sourcePreviewResults.compactMap { previewResult -> Source? in
switch previewResult.result {
case .success(let managedSource):
let id = managedSource.identifier
guard !seenIdentifiers.contains(id) else { return nil }
seenIdentifiers.insert(id)
return managedSource.wrappedValue
case .failure:
return nil
}
} }
} return orderedSources
.removeDuplicates { (sourceA: Managed<Source>?, sourceB: Managed<Source>?) in
sourceA?.identifier == sourceB?.identifier
} }
.receive(on: RunLoop.main) .receive(on: RunLoop.main)
.sink { [weak self] source in .sink { [weak self] sources in
self?.updateSourcePreview(for: source?.wrappedValue) self?.updateSourcesPreview(for: sources)
} }
.store(in: &self.cancellables) .store(in: &self.cancellables)
let addPublisher = NotificationCenter.default.publisher(for: AppManager.didAddSourceNotification) let mergedNotificationPublisher = Publishers.Merge(
let removePublisher = NotificationCenter.default.publisher(for: AppManager.didRemoveSourceNotification) NotificationCenter.default.publisher(for: AppManager.didAddSourceNotification),
Publishers.Merge(addPublisher, removePublisher) NotificationCenter.default.publisher(for: AppManager.didRemoveSourceNotification)
)
.receive(on: RunLoop.main)
.share() // Shares the upstream publisher with multiple subscribers
// Update recommended sources section when sources are added/removed
mergedNotificationPublisher
.compactMap { notification -> String? in .compactMap { notification -> String? in
guard let source = notification.object as? Source, guard let source = notification.object as? Source,
let context = source.managedObjectContext let context = source.managedObjectContext
@@ -399,7 +455,6 @@ private extension AddSourceViewController
let sourceID = context.performAndWait { source.identifier } let sourceID = context.performAndWait { source.identifier }
return sourceID return sourceID
} }
.receive(on: RunLoop.main)
.compactMap { [dataSource = recommendedSourcesDataSource] sourceID -> IndexPath? in .compactMap { [dataSource = recommendedSourcesDataSource] sourceID -> IndexPath? in
guard let index = dataSource.items.firstIndex(where: { $0.identifier == sourceID }) else { return nil } guard let index = dataSource.items.firstIndex(where: { $0.identifier == sourceID }) else { return nil }
@@ -411,6 +466,32 @@ private extension AddSourceViewController
self?.collectionView.reloadItems(at: [indexPath]) self?.collectionView.reloadItems(at: [indexPath])
} }
.store(in: &self.cancellables) .store(in: &self.cancellables)
// Update previews section when sources are added/removed
// mergedNotificationPublisher
// .sink { [weak self] _ in
// // reload the entire of previews section to get latest state
// self?.collectionView.reloadSections(IndexSet(integer: Section.preview.rawValue))
// }
// .store(in: &self.cancellables)
mergedNotificationPublisher
.compactMap { notification -> String? in
guard let source = notification.object as? Source,
let context = source.managedObjectContext
else { return nil }
return context.performAndWait { source.identifier }
}
.compactMap { [weak self] sourceID -> IndexPath? in
guard let dataSource = self?.sourcePreviewDataSource,
let index = dataSource.items.firstIndex(where: { $0.identifier == sourceID })
else { return nil }
return IndexPath(item: index, section: Section.preview.rawValue)
}
.sink { [weak self] indexPath in
self?.collectionView.reloadItems(at: [indexPath])
}
.store(in: &self.cancellables)
} }
func sourceURL(from address: String) -> URL? func sourceURL(from address: String) -> URL?
@@ -458,35 +539,51 @@ private extension AddSourceViewController
}) })
} }
func updateSourcePreview(for source: Source?) func updateSourcesPreview(for sources: [Source]) {
{ // Calculate changes needed to go from current items to new items
let items = [source].compactMap { $0 } let currentItemCount = self.sourcePreviewDataSource.items.count
let newItemCount = sources.count
// Have to provide changes in terms of sourcePreviewDataSource. var changes: [RSTCellContentChange] = []
let indexPath = IndexPath(row: 0, section: 0)
if !items.isEmpty && self.sourcePreviewDataSource.items.isEmpty if currentItemCount == 0 && newItemCount > 0 {
{ // Insert all items if we currently have none
let change = RSTCellContentChange(type: .insert, currentIndexPath: nil, destinationIndexPath: indexPath) for i in 0..<newItemCount {
self.sourcePreviewDataSource.setItems(items, with: [change]) let indexPath = IndexPath(row: i, section: 0)
} let change = RSTCellContentChange(type: .insert,
else if items.isEmpty && !self.sourcePreviewDataSource.items.isEmpty currentIndexPath: nil,
{ destinationIndexPath: indexPath)
let change = RSTCellContentChange(type: .delete, currentIndexPath: indexPath, destinationIndexPath: nil) changes.append(change)
self.sourcePreviewDataSource.setItems(items, with: [change]) }
} } else if currentItemCount > 0 && newItemCount == 0 {
else if !items.isEmpty && !self.sourcePreviewDataSource.items.isEmpty // Delete all items if we're going to have none
{ for i in 0..<currentItemCount {
let change = RSTCellContentChange(type: .update, currentIndexPath: indexPath, destinationIndexPath: indexPath) let indexPath = IndexPath(row: i, section: 0)
self.sourcePreviewDataSource.setItems(items, with: [change]) let change = RSTCellContentChange(type: .delete,
currentIndexPath: indexPath,
destinationIndexPath: nil)
changes.append(change)
}
} else if currentItemCount != newItemCount {
// If counts differ, do a section update
let change = RSTCellContentChange(type: .update, sectionIndex: 0)
changes = [change]
} else {
// Update existing items in place
for i in 0..<newItemCount {
let indexPath = IndexPath(row: i, section: 0)
let change = RSTCellContentChange(type: .update,
currentIndexPath: indexPath,
destinationIndexPath: indexPath)
changes.append(change)
}
} }
if source == nil self.sourcePreviewDataSource.setItems(sources, with: changes)
{
if sources.isEmpty {
self.collectionView.reloadSections([Section.preview.rawValue]) self.collectionView.reloadSections([Section.preview.rawValue])
} } else {
else
{
self.collectionView.collectionViewLayout.invalidateLayout() self.collectionView.collectionViewLayout.invalidateLayout()
} }
} }
@@ -510,9 +607,6 @@ private extension AddSourceViewController
cell.bannerView.iconImageView.isIndicatingActivity = true cell.bannerView.iconImageView.isIndicatingActivity = true
let config = UIImage.SymbolConfiguration(scale: .medium) 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.setTitle(nil, for: .normal)
cell.bannerView.button.imageView?.contentMode = .scaleAspectFit cell.bannerView.button.imageView?.contentMode = .scaleAspectFit
cell.bannerView.button.contentHorizontalAlignment = .fill // Fill entire button with imageView cell.bannerView.button.contentHorizontalAlignment = .fill // Fill entire button with imageView
@@ -521,28 +615,51 @@ private extension AddSourceViewController
cell.bannerView.button.tintColor = .clear cell.bannerView.button.tintColor = .clear
cell.bannerView.button.isHidden = false cell.bannerView.button.isHidden = false
func setButtonIcon()
{
Task<Void, Never>(priority: .userInitiated) { [weak cell] in
guard let cell else { return }
var isSourceAlreadyPersisted = false
do
{
isSourceAlreadyPersisted = try await source.isAdded
}
catch
{
print("Failed to determine if source is added.", error)
}
// use the plus icon by default
var buttonIcon = UIImage(systemName: "plus.circle.fill", withConfiguration: config)?.withTintColor(.white, renderingMode: .alwaysOriginal)
// if the source is already added/staged for adding, use the checkmark icon
let isStagedForAdd = self.stagedForAdd[source] == true
if isStagedForAdd || isSourceAlreadyPersisted
{
buttonIcon = UIImage(systemName: "checkmark.circle.fill", withConfiguration: config)?
.withTintColor(isSourceAlreadyPersisted ? .green : .white, renderingMode: .alwaysOriginal)
}
cell.bannerView.button.setImage(buttonIcon, for: .normal)
cell.bannerView.button.isEnabled = !isSourceAlreadyPersisted
}
}
// set the icon
setButtonIcon()
let action = UIAction(identifier: .addSource) { [weak self] _ in let action = UIAction(identifier: .addSource) { [weak self] _ in
self?.add(source) guard let self else { return }
self.stagedForAdd[source, default: false].toggle()
// update the button icon
setButtonIcon()
} }
cell.bannerView.button.addAction(action, for: .primaryActionTriggered) 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?) func configure(_ footerView: PlaceholderCollectionReusableView, with sourcePreviewResults: [SourcePreviewResult?])
{ {
footerView.placeholderView.stackView.isLayoutMarginsRelativeArrangement = false footerView.placeholderView.stackView.isLayoutMarginsRelativeArrangement = false
@@ -552,23 +669,33 @@ private extension AddSourceViewController
footerView.placeholderView.detailTextLabel.isHidden = true footerView.placeholderView.detailTextLabel.isHidden = true
switch sourcePreviewResult var errorText: String? = nil
var isError: Bool = false
for result in sourcePreviewResults
{ {
case (let sourceURL, .failure(let previewError))? where self.viewModel.sourceURL == sourceURL && !self.viewModel.isLoadingPreview: switch result
// The current URL matches the error being displayed, and we're not loading another preview, so show error. {
case (let sourceURL, .failure(let previewError))? where (self.viewModel.sourceURLs.contains(sourceURL) && !self.viewModel.isLoadingPreview):
footerView.placeholderView.textLabel.text = (previewError as NSError).localizedDebugDescription ?? previewError.localizedDescription // The current URL matches the error being displayed, and we're not loading another preview, so show error.
footerView.placeholderView.textLabel.isHidden = false
errorText = (previewError as NSError).localizedDebugDescription ?? previewError.localizedDescription
footerView.placeholderView.activityIndicatorView.stopAnimating() footerView.placeholderView.textLabel.text = errorText
footerView.placeholderView.textLabel.isHidden = false
default:
// The current URL does not match the URL of the source/error being displayed, so show loading indicator. isError = true
footerView.placeholderView.textLabel.text = nil default:
footerView.placeholderView.textLabel.isHidden = true // The current URL does not match the URL of the source/error being displayed, so show loading indicator.
errorText = nil
footerView.placeholderView.textLabel.isHidden = true
}
}
footerView.placeholderView.textLabel.text = errorText
if !isError{
footerView.placeholderView.activityIndicatorView.startAnimating() footerView.placeholderView.activityIndicatorView.startAnimating()
} else{
footerView.placeholderView.activityIndicatorView.stopAnimating()
} }
} }
@@ -652,30 +779,60 @@ private extension AddSourceViewController
} }
} }
func add(@AsyncManaged _ source: Source) @IBAction func commitChanges(_ sender: UIBarButtonItem)
{ {
Task<Void, Never> { struct StagedSource: Hashable {
do @AsyncManaged var source: Source
{
let isRecommended = await $source.isRecommended // Conformance for Equatable/Hashable by comparing the underlying source
if isRecommended static func == (lhs: StagedSource, rhs: StagedSource) -> Bool {
{ return lhs.source.identifier == rhs.source.identifier
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 func hash(into hasher: inout Hasher) {
{ hasher.combine(source)
let errorTitle = NSLocalizedString("Unable to Add Source", comment: "") }
await self.presentAlert(title: errorTitle, message: error.localizedDescription) }
Task<Void, Never> {
var isCancelled = false
// OK: COMMIT the staged changes now
// Convert the stagedForAdd dictionary into an array of StagedSource
let stagedSources: [StagedSource] = self.stagedForAdd.filter { $0.value }
.map { StagedSource(source: $0.key) }
for staged in stagedSources {
do
{
// Use the projected value to safely access isRecommended asynchronously
let isRecommended = await staged.$source.isRecommended
if isRecommended
{
try await AppManager.shared.add(staged.source, message: nil, presentingViewController: self)
}
else
{
// Use default message
try await AppManager.shared.add(staged.source, presentingViewController: self)
}
// remove this kv pair
self.stagedForAdd.removeValue(forKey: staged.source)
}
catch is CancellationError {
isCancelled = true
break
}
catch
{
let errorTitle = NSLocalizedString("Unable to Add Source", comment: "")
await self.presentAlert(title: errorTitle, message: error.localizedDescription)
}
}
if !isCancelled {
// finally dismiss the sheet/viewcontroller
self.dismiss()
} }
} }
} }
@@ -737,7 +894,7 @@ extension AddSourceViewController: UICollectionViewDelegateFlowLayout
case (.preview, UICollectionView.elementKindSectionFooter): case (.preview, UICollectionView.elementKindSectionFooter):
let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ReuseID.placeholderFooter.rawValue, for: indexPath) as! PlaceholderCollectionReusableView let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ReuseID.placeholderFooter.rawValue, for: indexPath) as! PlaceholderCollectionReusableView
self.configure(footerView, with: self.viewModel.sourcePreviewResult) self.configure(footerView, with: self.viewModel.sourcePreviewResults)
return footerView return footerView

View File

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<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"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23504" 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"/> <device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22131"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="collection view cell content view" minToolsVersion="11.0"/> <capability name="collection view cell content view" minToolsVersion="11.0"/>
@@ -224,6 +224,11 @@
<segue destination="qr8-ss-Ghz" kind="unwind" unwindAction="unwindFromAddSource:" id="Pba-Kh-qfH"/> <segue destination="qr8-ss-Ghz" kind="unwind" unwindAction="unwindFromAddSource:" id="Pba-Kh-qfH"/>
</connections> </connections>
</barButtonItem> </barButtonItem>
<barButtonItem key="rightBarButtonItem" systemItem="done" id="oza-rj-JhC">
<connections>
<action selector="commitChanges:" destination="bbz-wy-kaK" id="4FB-Sj-E14"/>
</connections>
</barButtonItem>
</navigationItem> </navigationItem>
<connections> <connections>
<segue destination="7XE-Wv-lf9" kind="show" identifier="showSourceDetails" destinationCreationSelector="makeSourceDetailViewController:sender:" id="LO9-iP-zZC"/> <segue destination="7XE-Wv-lf9" kind="show" identifier="showSourceDetails" destinationCreationSelector="makeSourceDetailViewController:sender:" id="LO9-iP-zZC"/>