mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-09 06:43:25 +01:00
317 lines
11 KiB
Swift
317 lines
11 KiB
Swift
//
|
|
// SourceDetailViewController.swift
|
|
// AltStore
|
|
//
|
|
// Created by Riley Testut on 3/15/23.
|
|
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
import SafariServices
|
|
import Combine
|
|
|
|
import AltStoreCore
|
|
import Roxas
|
|
|
|
import Nuke
|
|
|
|
extension SourceDetailViewController
|
|
{
|
|
private class ViewModel: ObservableObject
|
|
{
|
|
let source: Source
|
|
|
|
@Published
|
|
var isSourceAdded: Bool? = nil
|
|
|
|
@Published
|
|
var isAddingSource: Bool = false
|
|
|
|
init(source: Source)
|
|
{
|
|
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
|
|
{
|
|
self.isSourceAdded = try await self.source.isAdded
|
|
}
|
|
catch
|
|
{
|
|
print("[ALTLog] Failed to check if source is added.", error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
class SourceDetailViewController: HeaderContentViewController<SourceHeaderView, SourceDetailContentViewController>
|
|
{
|
|
@Managed private(set) var source: Source
|
|
|
|
var addedSourceHandler: ((Source) -> Void)?
|
|
|
|
private let viewModel: ViewModel
|
|
|
|
private var addButton: VibrantButton!
|
|
|
|
private var previousBounds: CGRect?
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
init?(source: Source, coder: NSCoder)
|
|
{
|
|
self.source = source
|
|
self.viewModel = ViewModel(source: source)
|
|
|
|
super.init(coder: coder)
|
|
|
|
self.title = source.name
|
|
self.tintColor = source.effectiveTintColor
|
|
}
|
|
|
|
required init?(coder: NSCoder)
|
|
{
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func viewDidLoad()
|
|
{
|
|
super.viewDidLoad()
|
|
|
|
self.addButton = VibrantButton(type: .system)
|
|
self.addButton.contentInsets = PillButton.contentInsets
|
|
self.addButton.addTarget(self, action: #selector(SourceDetailViewController.addSource), for: .primaryActionTriggered)
|
|
self.addButton.sizeToFit()
|
|
self.view.addSubview(self.addButton)
|
|
|
|
self.navigationBarButton.addTarget(self, action: #selector(SourceDetailViewController.addSource), for: .primaryActionTriggered)
|
|
|
|
Nuke.loadImage(with: self.source.effectiveIconURL, into: self.navigationBarIconView)
|
|
Nuke.loadImage(with: self.source.effectiveHeaderImageURL, into: self.backgroundImageView)
|
|
|
|
self.update()
|
|
self.preparePipeline()
|
|
}
|
|
|
|
override func viewDidLayoutSubviews()
|
|
{
|
|
super.viewDidLayoutSubviews()
|
|
|
|
self.addButton.layer.cornerRadius = self.addButton.bounds.midY
|
|
self.navigationBarIconView.layer.cornerRadius = self.navigationBarIconView.bounds.midY
|
|
|
|
var addButtonSize = self.addButton.sizeThatFits(CGSize(width: Double.infinity, height: .infinity))
|
|
addButtonSize.width = max(addButtonSize.width, PillButton.minimumSize.width)
|
|
addButtonSize.height = max(addButtonSize.height, PillButton.minimumSize.height)
|
|
self.addButton.frame.size = addButtonSize
|
|
|
|
// Place in top-right corner.
|
|
let inset = 15.0
|
|
self.addButton.center.y = self.backButton.center.y
|
|
self.addButton.frame.origin.x = self.view.bounds.width - inset - self.addButton.bounds.width
|
|
|
|
guard self.view.bounds != self.previousBounds else { return }
|
|
self.previousBounds = self.view.bounds
|
|
|
|
let headerSize = self.headerView.systemLayoutSizeFitting(CGSize(width: self.view.bounds.width - inset * 2, height: UIView.layoutFittingCompressedSize.height))
|
|
self.headerView.frame.size.height = headerSize.height
|
|
}
|
|
|
|
//MARK: Override
|
|
|
|
override func makeContentViewController() -> SourceDetailContentViewController
|
|
{
|
|
guard let storyboard = self.storyboard else { fatalError("SourceDetailViewController must be initialized via UIStoryboard.") }
|
|
|
|
let contentViewController = storyboard.instantiateViewController(identifier: "sourceDetailContentViewController") { coder in
|
|
SourceDetailContentViewController(source: self.source, coder: coder)
|
|
}
|
|
return contentViewController
|
|
}
|
|
|
|
override func makeHeaderView() -> SourceHeaderView
|
|
{
|
|
let sourceAboutView = SourceHeaderView(frame: CGRect(x: 0, y: 0, width: 375, height: 200))
|
|
sourceAboutView.configure(for: self.source)
|
|
sourceAboutView.websiteButton.addTarget(self, action: #selector(SourceDetailViewController.showWebsite), for: .primaryActionTriggered)
|
|
return sourceAboutView
|
|
}
|
|
|
|
override func update()
|
|
{
|
|
super.update()
|
|
|
|
if self.source.identifier == Source.altStoreIdentifier
|
|
{
|
|
// Users can't remove default AltStore source, so hide buttons.
|
|
self.addButton.isHidden = true
|
|
self.navigationBarButton.isHidden = true
|
|
}
|
|
else
|
|
{
|
|
// Update isIndicatingActivity first to ensure later updates are applied correctly.
|
|
self.addButton.isIndicatingActivity = self.viewModel.isAddingSource
|
|
self.navigationBarButton.isIndicatingActivity = self.viewModel.isAddingSource
|
|
|
|
let title: String
|
|
|
|
switch self.viewModel.isSourceAdded
|
|
{
|
|
case true?:
|
|
title = NSLocalizedString("REMOVE", comment: "")
|
|
self.navigationBarButton.tintColor = .refreshRed
|
|
|
|
self.addButton.isHidden = false
|
|
self.navigationBarButton.isHidden = false
|
|
|
|
if #available(iOS 16, *)
|
|
{
|
|
// Hide REMOVE button in navigation bar.
|
|
self.navigationItem.rightBarButtonItem?.isHidden = true
|
|
}
|
|
|
|
case false?:
|
|
title = NSLocalizedString("ADD", comment: "")
|
|
self.navigationBarButton.tintColor = self.source.effectiveTintColor?.adjustedForDisplay ?? .altPrimary
|
|
|
|
self.addButton.isHidden = false
|
|
self.navigationBarButton.isHidden = false
|
|
|
|
if #available(iOS 16, *)
|
|
{
|
|
// Show ADD button in navigation bar.
|
|
self.navigationItem.rightBarButtonItem?.isHidden = false
|
|
}
|
|
|
|
case nil:
|
|
title = ""
|
|
|
|
self.addButton.isHidden = true
|
|
self.navigationBarButton.isHidden = true
|
|
}
|
|
|
|
if title != self.addButton.title
|
|
{
|
|
self.addButton.title = title
|
|
}
|
|
|
|
if title != self.navigationBarButton.title(for: .normal) && !self.navigationBarButton.isIndicatingActivity
|
|
{
|
|
self.navigationBarButton.setTitle(title, for: .normal)
|
|
}
|
|
|
|
self.view.setNeedsLayout()
|
|
}
|
|
}
|
|
|
|
//MARK: Actions
|
|
|
|
@objc private func addSource()
|
|
{
|
|
self.viewModel.isAddingSource = true
|
|
|
|
Task<Void, Never> { /* @MainActor in */ // Already on MainActor, even though this function wasn't called from async context.
|
|
var errorTitle = NSLocalizedString("Unable to Add Source", comment: "")
|
|
|
|
do
|
|
{
|
|
let isAdded = try await self.source.isAdded
|
|
if isAdded
|
|
{
|
|
errorTitle = NSLocalizedString("Unable to Remove Source", comment: "")
|
|
try await AppManager.shared.remove(self.source, presentingViewController: self)
|
|
}
|
|
else
|
|
{
|
|
try await AppManager.shared.add(self.source, presentingViewController: self)
|
|
|
|
self.addedSourceHandler?(self.source)
|
|
}
|
|
}
|
|
catch is CancellationError {}
|
|
catch
|
|
{
|
|
await self.presentAlert(title: errorTitle, message: error.localizedDescription)
|
|
}
|
|
|
|
self.viewModel.isAddingSource = false
|
|
}
|
|
}
|
|
|
|
@objc private func showWebsite()
|
|
{
|
|
guard let websiteURL = self.source.websiteURL else { return }
|
|
|
|
let safariViewController = SFSafariViewController(url: websiteURL)
|
|
safariViewController.preferredControlTintColor = self.source.effectiveTintColor ?? .altPrimary
|
|
self.present(safariViewController, animated: true, completion: nil)
|
|
}
|
|
}
|
|
|
|
private extension SourceDetailViewController
|
|
{
|
|
func preparePipeline()
|
|
{
|
|
Publishers.CombineLatest(self.viewModel.$isSourceAdded, self.viewModel.$isAddingSource)
|
|
.receive(on: RunLoop.main)
|
|
.sink { [weak self] _ in
|
|
self?.update()
|
|
}
|
|
.store(in: &self.cancellables)
|
|
|
|
// Adding or removing a source while viewing source details is currently broken,
|
|
// so for now we just dismiss the view whenever the source is added or removed.
|
|
self.viewModel.$isSourceAdded
|
|
.compactMap { $0 }
|
|
.dropFirst() // Ignore first non-nil value.
|
|
.removeDuplicates()
|
|
.receive(on: RunLoop.main)
|
|
.sink { [weak self] isAdded in
|
|
if isAdded
|
|
{
|
|
self?.didAddSource()
|
|
}
|
|
else
|
|
{
|
|
self?.didRemoveSource()
|
|
}
|
|
}
|
|
.store(in: &self.cancellables)
|
|
}
|
|
|
|
func didAddSource()
|
|
{
|
|
guard let presentingViewController = self.navigationController?.presentingViewController else { return }
|
|
presentingViewController.dismiss(animated: true)
|
|
}
|
|
|
|
func didRemoveSource()
|
|
{
|
|
self.navigationController?.popToRootViewController(animated: true)
|
|
}
|
|
}
|