mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-16 18:23:53 +01:00
[AltStore] Adds redesigned MyAppsViewController to refresh/update installed apps
This commit is contained in:
46
AltStore/My Apps/MyAppsComponents.swift
Normal file
46
AltStore/My Apps/MyAppsComponents.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
//
|
||||
// MyAppsComponents.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/17/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class InstalledAppCollectionViewCell: UICollectionViewCell
|
||||
{
|
||||
@IBOutlet var appIconImageView: UIImageView!
|
||||
@IBOutlet var nameLabel: UILabel!
|
||||
@IBOutlet var developerLabel: UILabel!
|
||||
@IBOutlet var refreshButton: PillButton!
|
||||
}
|
||||
|
||||
class InstalledAppsCollectionHeaderView: UICollectionReusableView
|
||||
{
|
||||
@IBOutlet var textLabel: UILabel!
|
||||
@IBOutlet var button: UIButton!
|
||||
}
|
||||
|
||||
class UpdatesCollectionHeaderView: UICollectionReusableView
|
||||
{
|
||||
let button = PillButton(type: .system)
|
||||
|
||||
override init(frame: CGRect)
|
||||
{
|
||||
super.init(frame: frame)
|
||||
|
||||
self.button.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.button.setTitle(">", for: .normal)
|
||||
self.addSubview(self.button)
|
||||
|
||||
NSLayoutConstraint.activate([self.button.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -20),
|
||||
self.button.topAnchor.constraint(equalTo: self.topAnchor),
|
||||
self.button.widthAnchor.constraint(equalToConstant: 50),
|
||||
self.button.heightAnchor.constraint(equalToConstant: 26)])
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
@@ -2,129 +2,222 @@
|
||||
// MyAppsViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/9/19.
|
||||
// Created by Riley Testut on 7/16/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import AltKit
|
||||
import Roxas
|
||||
|
||||
import AltSign
|
||||
private let maximumCollapsedUpdatesCount = 2
|
||||
|
||||
class MyAppsViewController: UITableViewController
|
||||
extension MyAppsViewController
|
||||
{
|
||||
private enum Section: Int, CaseIterable
|
||||
{
|
||||
case updates
|
||||
case installedApps
|
||||
}
|
||||
}
|
||||
|
||||
private extension Date
|
||||
{
|
||||
func numberOfCalendarDays(since date: Date) -> Int
|
||||
{
|
||||
let today = Calendar.current.startOfDay(for: self)
|
||||
let previousDay = Calendar.current.startOfDay(for: date)
|
||||
|
||||
let components = Calendar.current.dateComponents([.day], from: previousDay, to: today)
|
||||
return components.day!
|
||||
}
|
||||
}
|
||||
|
||||
class MyAppsViewController: UICollectionViewController
|
||||
{
|
||||
private var refreshErrors = [String: Error]()
|
||||
|
||||
private lazy var dataSource = self.makeDataSource()
|
||||
private lazy var updatesDataSource = self.makeUpdatesDataSource()
|
||||
private lazy var installedAppsDataSource = self.makeInstalledAppsDataSource()
|
||||
|
||||
private var prototypeUpdateCell: UpdateCollectionViewCell!
|
||||
|
||||
// State
|
||||
private var isUpdateSectionCollapsed = true
|
||||
private var expandedAppUpdates = Set<String>()
|
||||
private var isRefreshingAllApps = false
|
||||
private var refreshGroup: OperationGroup?
|
||||
|
||||
// Cache
|
||||
private var cachedUpdateSizes = [String: CGSize]()
|
||||
|
||||
private lazy var dateFormatter: DateFormatter = {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .short
|
||||
dateFormatter.timeStyle = .short
|
||||
dateFormatter.dateStyle = .medium
|
||||
dateFormatter.timeStyle = .none
|
||||
return dateFormatter
|
||||
}()
|
||||
|
||||
private var refreshGroup: OperationGroup?
|
||||
|
||||
@IBOutlet private var progressView: UIProgressView!
|
||||
required init?(coder aDecoder: NSCoder)
|
||||
{
|
||||
super.init(coder: aDecoder)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(MyAppsViewController.didFetchApps(_:)), name: AppManager.didFetchAppsNotification, object: nil)
|
||||
}
|
||||
|
||||
override func viewDidLoad()
|
||||
{
|
||||
super.viewDidLoad()
|
||||
|
||||
self.tableView.dataSource = self.dataSource
|
||||
self.collectionView.dataSource = self.dataSource
|
||||
self.collectionView.prefetchDataSource = self.dataSource
|
||||
|
||||
self.prototypeUpdateCell = UpdateCollectionViewCell.instantiate(with: UpdateCollectionViewCell.nib!)
|
||||
self.prototypeUpdateCell.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.prototypeUpdateCell.contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
if let navigationBar = self.navigationController?.navigationBar
|
||||
{
|
||||
self.progressView.translatesAutoresizingMaskIntoConstraints = false
|
||||
navigationBar.addSubview(self.progressView)
|
||||
|
||||
NSLayoutConstraint.activate([self.progressView.widthAnchor.constraint(equalTo: navigationBar.widthAnchor),
|
||||
self.progressView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor)])
|
||||
}
|
||||
|
||||
self.update()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool)
|
||||
{
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
self.update()
|
||||
}
|
||||
|
||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?)
|
||||
{
|
||||
guard segue.identifier == "showAppDetail" else { return }
|
||||
|
||||
guard let cell = sender as? UITableViewCell, let indexPath = self.tableView.indexPath(for: cell) else { return }
|
||||
|
||||
let installedApp = self.dataSource.item(at: indexPath)
|
||||
guard let app = installedApp.app else { return }
|
||||
|
||||
let appDetailViewController = segue.destination as! AppDetailViewController
|
||||
appDetailViewController.app = app
|
||||
self.collectionView.register(UpdateCollectionViewCell.nib, forCellWithReuseIdentifier: "UpdateCell")
|
||||
self.collectionView.register(UpdatesCollectionHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "UpdatesHeader")
|
||||
}
|
||||
}
|
||||
|
||||
private extension MyAppsViewController
|
||||
{
|
||||
func makeDataSource() -> RSTFetchedResultsTableViewDataSource<InstalledApp>
|
||||
func makeDataSource() -> RSTCompositeCollectionViewPrefetchingDataSource<InstalledApp, UIImage>
|
||||
{
|
||||
let dataSource = RSTCompositeCollectionViewPrefetchingDataSource<InstalledApp, UIImage>(dataSources: [self.updatesDataSource, self.installedAppsDataSource])
|
||||
dataSource.proxy = self
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func makeUpdatesDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage>
|
||||
{
|
||||
let fetchRequest = InstalledApp.fetchRequest() as NSFetchRequest<InstalledApp>
|
||||
fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(InstalledApp.app)]
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \InstalledApp.app?.name, ascending: true)]
|
||||
fetchRequest.predicate = NSPredicate(format: "%K != %K", #keyPath(InstalledApp.version), #keyPath(InstalledApp.app.version))
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \InstalledApp.app?.versionDate, ascending: true),
|
||||
NSSortDescriptor(keyPath: \InstalledApp.app?.name, ascending: true)]
|
||||
fetchRequest.returnsObjectsAsFaults = false
|
||||
|
||||
let dataSource = RSTFetchedResultsTableViewDataSource(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
||||
dataSource.proxy = self
|
||||
dataSource.cellConfigurationHandler = { [weak self] (cell, installedApp, indexPath) in
|
||||
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
||||
dataSource.liveFetchLimit = maximumCollapsedUpdatesCount
|
||||
dataSource.cellIdentifierHandler = { _ in "UpdateCell" }
|
||||
dataSource.cellConfigurationHandler = { (cell, installedApp, indexPath) in
|
||||
guard let app = installedApp.app else { return }
|
||||
|
||||
cell.textLabel?.text = app.name + " (\(installedApp.version))"
|
||||
let cell = cell as! UpdateCollectionViewCell
|
||||
cell.tintColor = app.tintColor ?? .altGreen
|
||||
cell.nameLabel.text = app.name
|
||||
cell.versionDescriptionTextView.text = app.versionDescription
|
||||
cell.appIconImageView.image = UIImage(named: app.iconName)
|
||||
|
||||
let detailText =
|
||||
"""
|
||||
Expires: \(self?.dateFormatter.string(from: installedApp.expirationDate) ?? "-")
|
||||
"""
|
||||
cell.updateButton.isIndicatingActivity = false
|
||||
cell.updateButton.addTarget(self, action: #selector(MyAppsViewController.updateApp(_:)), for: .primaryActionTriggered)
|
||||
|
||||
cell.detailTextLabel?.numberOfLines = 1
|
||||
cell.detailTextLabel?.text = detailText
|
||||
cell.detailTextLabel?.textColor = .red
|
||||
|
||||
if let _ = self?.refreshErrors[installedApp.bundleIdentifier]
|
||||
if self.expandedAppUpdates.contains(app.identifier)
|
||||
{
|
||||
cell.accessoryType = .detailButton
|
||||
cell.tintColor = .red
|
||||
cell.mode = .expanded
|
||||
}
|
||||
else
|
||||
{
|
||||
cell.accessoryType = .none
|
||||
cell.tintColor = nil
|
||||
cell.mode = .collapsed
|
||||
}
|
||||
|
||||
cell.moreButton.addTarget(self, action: #selector(MyAppsViewController.toggleUpdateCellMode(_:)), for: .primaryActionTriggered)
|
||||
|
||||
let progress = AppManager.shared.installationProgress(for: app)
|
||||
cell.updateButton.progress = progress
|
||||
|
||||
cell.dateLabel.text = self.dateFormatter.string(from: app.versionDate)
|
||||
|
||||
let numberOfDays = Date().numberOfCalendarDays(since: app.versionDate)
|
||||
switch numberOfDays
|
||||
{
|
||||
case 0: cell.dateLabel.text = NSLocalizedString("Today", comment: "")
|
||||
case 1: cell.dateLabel.text = NSLocalizedString("Yesterday", comment: "")
|
||||
case 2...7: cell.dateLabel.text = String(format: NSLocalizedString("%@ days ago", comment: ""), NSNumber(value: numberOfDays))
|
||||
default: cell.dateLabel.text = self.dateFormatter.string(from: app.versionDate)
|
||||
}
|
||||
|
||||
cell.setNeedsLayout()
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func update()
|
||||
func makeInstalledAppsDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage>
|
||||
{
|
||||
self.navigationItem.rightBarButtonItem?.isEnabled = !(self.dataSource.fetchedResultsController.fetchedObjects?.isEmpty ?? true)
|
||||
let fetchRequest = InstalledApp.fetchRequest() as NSFetchRequest<InstalledApp>
|
||||
fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(InstalledApp.app)]
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true),
|
||||
NSSortDescriptor(keyPath: \InstalledApp.refreshedDate, ascending: false),
|
||||
NSSortDescriptor(keyPath: \InstalledApp.app?.name, ascending: true)]
|
||||
fetchRequest.returnsObjectsAsFaults = false
|
||||
|
||||
self.tableView.reloadData()
|
||||
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
||||
dataSource.cellIdentifierHandler = { _ in "AppCell" }
|
||||
dataSource.cellConfigurationHandler = { (cell, installedApp, indexPath) in
|
||||
guard let app = installedApp.app else { return }
|
||||
|
||||
let tintColor = app.tintColor ?? .altGreen
|
||||
|
||||
let cell = cell as! InstalledAppCollectionViewCell
|
||||
cell.tintColor = tintColor
|
||||
cell.appIconImageView.image = UIImage(named: app.iconName)
|
||||
cell.refreshButton.isIndicatingActivity = false
|
||||
cell.refreshButton.addTarget(self, action: #selector(MyAppsViewController.refreshApp(_:)), for: .primaryActionTriggered)
|
||||
|
||||
let currentDate = Date()
|
||||
|
||||
let numberOfDays = installedApp.expirationDate.numberOfCalendarDays(since: currentDate)
|
||||
|
||||
if numberOfDays == 1
|
||||
{
|
||||
cell.refreshButton.setTitle(NSLocalizedString("1 DAY", comment: ""), for: .normal)
|
||||
}
|
||||
else
|
||||
{
|
||||
cell.refreshButton.setTitle(String(format: NSLocalizedString("%@ DAYS", comment: ""), NSNumber(value: numberOfDays)), for: .normal)
|
||||
}
|
||||
|
||||
cell.nameLabel.text = app.name
|
||||
cell.developerLabel.text = app.developerName
|
||||
|
||||
// Make sure refresh button is correct size.
|
||||
cell.layoutIfNeeded()
|
||||
|
||||
switch numberOfDays
|
||||
{
|
||||
case 2...3: cell.refreshButton.tintColor = .refreshOrange
|
||||
case 4...5: cell.refreshButton.tintColor = .refreshYellow
|
||||
case 6...: cell.refreshButton.tintColor = .refreshGreen
|
||||
default: cell.refreshButton.tintColor = .refreshRed
|
||||
}
|
||||
|
||||
if let refreshGroup = self.refreshGroup, let progress = refreshGroup.progress(for: app), progress.fractionCompleted < 1.0
|
||||
{
|
||||
cell.refreshButton.progress = progress
|
||||
}
|
||||
else
|
||||
{
|
||||
cell.refreshButton.progress = nil
|
||||
}
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
}
|
||||
|
||||
private extension MyAppsViewController
|
||||
{
|
||||
@IBAction func refreshAllApps(_ sender: UIBarButtonItem)
|
||||
func update()
|
||||
{
|
||||
sender.isIndicatingActivity = true
|
||||
|
||||
let installedApps = InstalledApp.fetchAppsForRefreshingAll(in: DatabaseManager.shared.viewContext)
|
||||
|
||||
self.refresh(installedApps) { (result) in
|
||||
sender.isIndicatingActivity = false
|
||||
if self.updatesDataSource.itemCount > 0
|
||||
{
|
||||
self.navigationController?.tabBarItem.badgeValue = String(describing: self.updatesDataSource.itemCount)
|
||||
}
|
||||
else
|
||||
{
|
||||
self.navigationController?.tabBarItem.badgeValue = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,74 +225,60 @@ private extension MyAppsViewController
|
||||
{
|
||||
func refresh()
|
||||
{
|
||||
if self.refreshGroup == nil
|
||||
{
|
||||
let toastView = RSTToastView(text: "Refreshing...", detailText: nil)
|
||||
toastView.tintColor = .altPurple
|
||||
toastView.activityIndicatorView.startAnimating()
|
||||
toastView.show(in: self.navigationController?.view ?? self.view)
|
||||
}
|
||||
|
||||
let group = AppManager.shared.refresh(installedApps, presentingViewController: self, group: self.refreshGroup)
|
||||
group.completionHandler = { (result) in
|
||||
DispatchQueue.main.async {
|
||||
switch result
|
||||
{
|
||||
case .failure(let error):
|
||||
let toastView = RSTToastView(text: error.localizedDescription, detailText: nil)
|
||||
toastView.tintColor = .red
|
||||
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
|
||||
toastView.tintColor = .refreshRed
|
||||
toastView.setNeedsLayout()
|
||||
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
|
||||
|
||||
self.refreshErrors = [:]
|
||||
|
||||
case .success(let results):
|
||||
let failures = results.compactMapValues { $0.error }
|
||||
let failures = results.compactMapValues { (result) -> Error? in
|
||||
switch result
|
||||
{
|
||||
case .failure(OperationError.cancelled): return nil
|
||||
case .failure(let error): return error
|
||||
case .success: return nil
|
||||
}
|
||||
}
|
||||
|
||||
if failures.isEmpty
|
||||
guard !failures.isEmpty else { break }
|
||||
|
||||
let localizedText: String
|
||||
if let failure = failures.first, failures.count == 1
|
||||
{
|
||||
let toastView = RSTToastView(text: NSLocalizedString("Successfully refreshed apps!", comment: ""), detailText: nil)
|
||||
toastView.tintColor = .altPurple
|
||||
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
|
||||
localizedText = failure.value.localizedDescription
|
||||
}
|
||||
else
|
||||
{
|
||||
let localizedText: String
|
||||
if failures.count == 1
|
||||
{
|
||||
localizedText = String(format: NSLocalizedString("Failed to refresh %@ app.", comment: ""), NSNumber(value: failures.count))
|
||||
}
|
||||
else
|
||||
{
|
||||
localizedText = String(format: NSLocalizedString("Failed to refresh %@ apps.", comment: ""), NSNumber(value: failures.count))
|
||||
}
|
||||
|
||||
let toastView = RSTToastView(text: localizedText, detailText: nil)
|
||||
toastView.tintColor = .red
|
||||
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
|
||||
localizedText = String(format: NSLocalizedString("Failed to refresh %@ apps.", comment: ""), NSNumber(value: failures.count))
|
||||
}
|
||||
|
||||
self.refreshErrors = failures
|
||||
let toastView = ToastView(text: localizedText, detailText: nil)
|
||||
toastView.tintColor = .refreshRed
|
||||
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
|
||||
}
|
||||
|
||||
self.progressView.observedProgress = nil
|
||||
self.progressView.progress = 0.0
|
||||
|
||||
self.update()
|
||||
|
||||
self.refreshGroup = nil
|
||||
completionHandler(result)
|
||||
}
|
||||
}
|
||||
|
||||
self.progressView.observedProgress = group.progress
|
||||
|
||||
self.refreshGroup = group
|
||||
|
||||
self.collectionView.reloadSections(IndexSet(integer: Section.installedApps.rawValue))
|
||||
}
|
||||
|
||||
if installedApps.contains(where: { $0.app.identifier == App.altstoreAppID })
|
||||
{
|
||||
let alertController = UIAlertController(title: NSLocalizedString("Refresh AltStore?", comment: ""), message: NSLocalizedString("AltStore will quit when it is finished refreshing.", comment: ""), preferredStyle: .alert)
|
||||
alertController.addAction(.cancel)
|
||||
alertController.addAction(UIAlertAction(title: RSTSystemLocalizedString("Cancel"), style: .cancel) { (action) in
|
||||
completionHandler(.failure(OperationError.cancelled))
|
||||
})
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Refresh", comment: ""), style: .default) { (action) in
|
||||
refresh()
|
||||
})
|
||||
@@ -212,50 +291,248 @@ private extension MyAppsViewController
|
||||
}
|
||||
}
|
||||
|
||||
extension MyAppsViewController
|
||||
private extension MyAppsViewController
|
||||
{
|
||||
override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]?
|
||||
@IBAction func toggleAppUpdates(_ sender: UIButton)
|
||||
{
|
||||
let deleteAction = UITableViewRowAction(style: .destructive, title: "Delete") { (action, indexPath) in
|
||||
let installedApp = self.dataSource.item(at: indexPath)
|
||||
let visibleCells = self.collectionView.visibleCells
|
||||
|
||||
self.collectionView.performBatchUpdates({
|
||||
|
||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
|
||||
let installedApp = context.object(with: installedApp.objectID) as! InstalledApp
|
||||
context.delete(installedApp)
|
||||
|
||||
do
|
||||
self.isUpdateSectionCollapsed.toggle()
|
||||
|
||||
UIView.animate(withDuration: 0.3, animations: {
|
||||
if self.isUpdateSectionCollapsed
|
||||
{
|
||||
try context.save()
|
||||
self.updatesDataSource.liveFetchLimit = maximumCollapsedUpdatesCount
|
||||
self.expandedAppUpdates.removeAll()
|
||||
|
||||
for case let cell as UpdateCollectionViewCell in visibleCells
|
||||
{
|
||||
cell.mode = .collapsed
|
||||
}
|
||||
|
||||
self.cachedUpdateSizes.removeAll()
|
||||
|
||||
sender.titleLabel?.transform = .identity
|
||||
}
|
||||
catch
|
||||
else
|
||||
{
|
||||
print("Failed to delete installed app.", error)
|
||||
self.updatesDataSource.liveFetchLimit = 0
|
||||
|
||||
sender.titleLabel?.transform = CGAffineTransform.identity.rotated(by: .pi)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let refreshAction = UITableViewRowAction(style: .normal, title: "Refresh") { (action, indexPath) in
|
||||
let installedApp = self.dataSource.item(at: indexPath)
|
||||
self.refresh([installedApp]) { (result) in
|
||||
print("Refreshed", installedApp.app.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
return [deleteAction, refreshAction]
|
||||
})
|
||||
|
||||
self.collectionView.collectionViewLayout.invalidateLayout()
|
||||
|
||||
}, completion: nil)
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath)
|
||||
{
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, accessoryButtonTappedForRowWith indexPath: IndexPath)
|
||||
@IBAction func toggleUpdateCellMode(_ sender: UIButton)
|
||||
{
|
||||
let point = self.collectionView.convert(sender.center, from: sender.superview)
|
||||
guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return }
|
||||
|
||||
let installedApp = self.dataSource.item(at: indexPath)
|
||||
|
||||
guard let error = self.refreshErrors[installedApp.bundleIdentifier] else { return }
|
||||
let cell = self.collectionView.cellForItem(at: indexPath) as? UpdateCollectionViewCell
|
||||
|
||||
let alertController = UIAlertController(title: "Failed to Refresh \(installedApp.app.name)", message: error.localizedDescription, preferredStyle: .alert)
|
||||
alertController.addAction(.ok)
|
||||
self.present(alertController, animated: true, completion: nil)
|
||||
if self.expandedAppUpdates.contains(installedApp.app.identifier)
|
||||
{
|
||||
self.expandedAppUpdates.remove(installedApp.app.identifier)
|
||||
cell?.mode = .collapsed
|
||||
}
|
||||
else
|
||||
{
|
||||
self.expandedAppUpdates.insert(installedApp.app.identifier)
|
||||
cell?.mode = .expanded
|
||||
}
|
||||
|
||||
self.cachedUpdateSizes[installedApp.app.identifier] = nil
|
||||
|
||||
self.collectionView.performBatchUpdates({
|
||||
self.collectionView.collectionViewLayout.invalidateLayout()
|
||||
}, completion: nil)
|
||||
}
|
||||
|
||||
@IBAction func refreshApp(_ sender: UIButton)
|
||||
{
|
||||
let point = self.collectionView.convert(sender.center, from: sender.superview)
|
||||
guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return }
|
||||
|
||||
let installedApp = self.dataSource.item(at: indexPath)
|
||||
|
||||
let previousProgress = AppManager.shared.refreshProgress(for: installedApp.app)
|
||||
guard previousProgress == nil else {
|
||||
previousProgress?.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
self.refresh([installedApp]) { (result) in
|
||||
DispatchQueue.main.async {
|
||||
self.collectionView.reloadSections(IndexSet(integer: Section.installedApps.rawValue))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func refreshAllApps(_ sender: UIBarButtonItem)
|
||||
{
|
||||
self.isRefreshingAllApps = true
|
||||
self.collectionView.collectionViewLayout.invalidateLayout()
|
||||
|
||||
let installedApps = InstalledApp.fetchAppsForRefreshingAll(in: DatabaseManager.shared.viewContext)
|
||||
|
||||
self.refresh(installedApps) { (result) in
|
||||
DispatchQueue.main.async {
|
||||
self.isRefreshingAllApps = false
|
||||
self.collectionView.reloadSections(IndexSet(integer: Section.installedApps.rawValue))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func updateApp(_ sender: UIButton)
|
||||
{
|
||||
let point = self.collectionView.convert(sender.center, from: sender.superview)
|
||||
guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return }
|
||||
|
||||
let app = self.dataSource.item(at: indexPath).app!
|
||||
|
||||
let previousProgress = AppManager.shared.installationProgress(for: app)
|
||||
guard previousProgress == nil else {
|
||||
previousProgress?.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
_ = AppManager.shared.install(app, presentingViewController: self) { (result) in
|
||||
DispatchQueue.main.async {
|
||||
switch result
|
||||
{
|
||||
case .failure(OperationError.cancelled):
|
||||
self.collectionView.reloadItems(at: [indexPath])
|
||||
|
||||
case .failure(let error):
|
||||
let toastView = RSTToastView(text: "Failed to update \(app.name)", detailText: error.localizedDescription)
|
||||
toastView.tintColor = .altGreen
|
||||
toastView.show(in: self.navigationController!.view, duration: 2)
|
||||
|
||||
self.collectionView.reloadItems(at: [indexPath])
|
||||
|
||||
case .success:
|
||||
print("Updated app:", app.identifier)
|
||||
// No need to reload, since the the update cell is gone now.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.collectionView.reloadItems(at: [indexPath])
|
||||
}
|
||||
|
||||
@objc func didFetchApps(_ notification: Notification)
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
if self.updatesDataSource.fetchedResultsController.fetchedObjects == nil
|
||||
{
|
||||
do { try self.updatesDataSource.fetchedResultsController.performFetch() }
|
||||
catch { print("Error fetching:", error) }
|
||||
}
|
||||
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MyAppsViewController
|
||||
{
|
||||
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
|
||||
{
|
||||
if indexPath.section == 0
|
||||
{
|
||||
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "UpdatesHeader", for: indexPath) as! UpdatesCollectionHeaderView
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
headerView.button.backgroundColor = UIColor.altGreen.withAlphaComponent(0.15)
|
||||
headerView.button.setTitle("▾", for: .normal)
|
||||
headerView.button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 28)
|
||||
headerView.button.setTitleColor(.altGreen, for: .normal)
|
||||
headerView.button.addTarget(self, action: #selector(MyAppsViewController.toggleAppUpdates), for: .primaryActionTriggered)
|
||||
|
||||
headerView.button.layoutIfNeeded()
|
||||
}
|
||||
|
||||
return headerView
|
||||
}
|
||||
else
|
||||
{
|
||||
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "InstalledAppsHeader", for: indexPath) as! InstalledAppsCollectionHeaderView
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
headerView.textLabel.text = NSLocalizedString("Installed", comment: "")
|
||||
|
||||
headerView.button.isIndicatingActivity = false
|
||||
headerView.button.activityIndicatorView.color = .altGreen
|
||||
headerView.button.setTitle(NSLocalizedString("Refresh All", comment: ""), for: .normal)
|
||||
headerView.button.addTarget(self, action: #selector(MyAppsViewController.refreshAllApps(_:)), for: .primaryActionTriggered)
|
||||
headerView.button.isIndicatingActivity = self.isRefreshingAllApps
|
||||
|
||||
headerView.button.layoutIfNeeded()
|
||||
}
|
||||
|
||||
return headerView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MyAppsViewController: UICollectionViewDelegateFlowLayout
|
||||
{
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
|
||||
{
|
||||
let section = Section.allCases[indexPath.section]
|
||||
switch section
|
||||
{
|
||||
case .updates:
|
||||
let item = self.dataSource.item(at: indexPath)
|
||||
|
||||
if let previousHeight = self.cachedUpdateSizes[item.app!.identifier]
|
||||
{
|
||||
return previousHeight
|
||||
}
|
||||
|
||||
let padding = 30 as CGFloat
|
||||
let width = collectionView.bounds.width - padding
|
||||
|
||||
let widthConstraint = self.prototypeUpdateCell.contentView.widthAnchor.constraint(equalToConstant: width)
|
||||
NSLayoutConstraint.activate([widthConstraint])
|
||||
defer { NSLayoutConstraint.deactivate([widthConstraint]) }
|
||||
|
||||
self.dataSource.cellConfigurationHandler(self.prototypeUpdateCell, item, indexPath)
|
||||
|
||||
let size = self.prototypeUpdateCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||
self.cachedUpdateSizes[item.app!.identifier] = size
|
||||
return size
|
||||
|
||||
case .installedApps:
|
||||
return CGSize(width: collectionView.bounds.width, height: 60)
|
||||
}
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize
|
||||
{
|
||||
let section = Section.allCases[section]
|
||||
switch section
|
||||
{
|
||||
case .updates: return CGSize(width: collectionView.bounds.width, height: 26)
|
||||
case .installedApps: return CGSize(width: collectionView.bounds.width, height: 29)
|
||||
}
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets
|
||||
{
|
||||
let section = Section.allCases[section]
|
||||
switch section
|
||||
{
|
||||
case .updates: return UIEdgeInsets(top: 12, left: 15, bottom: 20, right: 15)
|
||||
case .installedApps: return UIEdgeInsets(top: 13, left: 0, bottom: 20, right: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
121
AltStore/My Apps/UpdateCollectionViewCell.swift
Normal file
121
AltStore/My Apps/UpdateCollectionViewCell.swift
Normal file
@@ -0,0 +1,121 @@
|
||||
//
|
||||
// UpdateCollectionViewCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/16/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UpdateCollectionViewCell
|
||||
{
|
||||
enum Mode
|
||||
{
|
||||
case collapsed
|
||||
case expanded
|
||||
}
|
||||
}
|
||||
|
||||
@objc class UpdateCollectionViewCell: UICollectionViewCell
|
||||
{
|
||||
var mode: Mode = .expanded {
|
||||
didSet {
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
|
||||
@IBOutlet var appIconImageView: UIImageView!
|
||||
@IBOutlet var nameLabel: UILabel!
|
||||
@IBOutlet var dateLabel: UILabel!
|
||||
@IBOutlet var updateButton: PillButton!
|
||||
@IBOutlet var versionDescriptionTitleLabel: UILabel!
|
||||
@IBOutlet var versionDescriptionTextView: UITextView!
|
||||
|
||||
@IBOutlet var moreButton: UIButton!
|
||||
|
||||
override func awakeFromNib()
|
||||
{
|
||||
super.awakeFromNib()
|
||||
|
||||
self.contentView.layer.cornerRadius = 20
|
||||
self.contentView.layer.masksToBounds = true
|
||||
|
||||
self.versionDescriptionTextView.textContainerInset = .zero
|
||||
self.versionDescriptionTextView.textContainer.lineFragmentPadding = 0
|
||||
self.versionDescriptionTextView.textContainer.lineBreakMode = .byTruncatingTail
|
||||
self.versionDescriptionTextView.textContainer.heightTracksTextView = true
|
||||
|
||||
self.update()
|
||||
}
|
||||
|
||||
override func tintColorDidChange()
|
||||
{
|
||||
super.tintColorDidChange()
|
||||
|
||||
self.update()
|
||||
}
|
||||
|
||||
override func layoutSubviews()
|
||||
{
|
||||
super.layoutSubviews()
|
||||
|
||||
let textContainer = self.versionDescriptionTextView.textContainer
|
||||
|
||||
switch self.mode
|
||||
{
|
||||
case .collapsed:
|
||||
// Extra wide to make sure it wraps to next line.
|
||||
let frame = CGRect(x: textContainer.size.width - self.moreButton.bounds.width - 8,
|
||||
y: textContainer.size.height - 4,
|
||||
width: textContainer.size.width,
|
||||
height: textContainer.size.height)
|
||||
|
||||
textContainer.maximumNumberOfLines = 2
|
||||
textContainer.exclusionPaths = [UIBezierPath(rect: frame)]
|
||||
|
||||
if let font = self.versionDescriptionTextView.font, self.versionDescriptionTextView.bounds.height > font.lineHeight * 1.5
|
||||
{
|
||||
self.moreButton.isHidden = false
|
||||
}
|
||||
else
|
||||
{
|
||||
// One (or less) lines, so hide more button.
|
||||
self.moreButton.isHidden = true
|
||||
}
|
||||
|
||||
case .expanded:
|
||||
textContainer.maximumNumberOfLines = 10
|
||||
textContainer.exclusionPaths = []
|
||||
|
||||
self.moreButton.isHidden = true
|
||||
}
|
||||
|
||||
self.versionDescriptionTextView.invalidateIntrinsicContentSize()
|
||||
}
|
||||
|
||||
override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes)
|
||||
{
|
||||
// Animates transition to new attributes.
|
||||
let animator = UIViewPropertyAnimator(springTimingParameters: UISpringTimingParameters()) {
|
||||
self.layoutIfNeeded()
|
||||
}
|
||||
animator.startAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
private extension UpdateCollectionViewCell
|
||||
{
|
||||
func update()
|
||||
{
|
||||
self.versionDescriptionTitleLabel.textColor = self.tintColor
|
||||
self.contentView.backgroundColor = self.tintColor.withAlphaComponent(0.1)
|
||||
|
||||
self.updateButton.setTitleColor(self.tintColor, for: .normal)
|
||||
self.updateButton.backgroundColor = self.tintColor.withAlphaComponent(0.15)
|
||||
self.updateButton.progressTintColor = self.tintColor
|
||||
|
||||
self.setNeedsLayout()
|
||||
self.layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
136
AltStore/My Apps/UpdateCollectionViewCell.xib
Normal file
136
AltStore/My Apps/UpdateCollectionViewCell.xib
Normal file
@@ -0,0 +1,136 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_1" orientation="portrait">
|
||||
<adaptation id="fullscreen"/>
|
||||
</device>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14490.49"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="UpdateCell" id="Kqf-Pv-ca3" customClass="UpdateCollectionViewCell" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="133.5"/>
|
||||
<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="375" height="133.5"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="dmf-hv-bwx">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="133.5"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="14" translatesAutoresizingMaskIntoConstraints="NO" id="57X-Ep-rfq">
|
||||
<rect key="frame" x="20" y="20" width="340" height="93.5"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="11" translatesAutoresizingMaskIntoConstraints="NO" id="H0T-dR-3In" userLabel="App Info">
|
||||
<rect key="frame" x="0.0" y="0.0" width="340" height="65"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="jg6-wi-ngb" customClass="AppIconImageView" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="65" height="65"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="65" id="W3C-hH-1Ii"/>
|
||||
<constraint firstAttribute="width" secondItem="jg6-wi-ngb" secondAttribute="height" multiplier="1:1" id="vt3-Qt-m21"/>
|
||||
</constraints>
|
||||
</imageView>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="100" axis="vertical" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="2Ii-Hu-4ru">
|
||||
<rect key="frame" x="76" y="14" width="172" height="37"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="251" text="Short" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="qmI-m4-Mra">
|
||||
<rect key="frame" x="0.0" y="0.0" width="172" height="20.5"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="251" text="Developer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="xaB-Kc-Par">
|
||||
<rect key="frame" x="0.0" y="22.5" width="172" height="14.5"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleCaption1"/>
|
||||
<color key="textColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</stackView>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="OSL-U2-BKa" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="259" y="17" width="81" height="31"/>
|
||||
<color key="backgroundColor" red="0.22352941179999999" green="0.4941176471" blue="0.39607843139999999" alpha="0.10000000000000001" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="81" id="3yj-p0-NuE"/>
|
||||
<constraint firstAttribute="height" constant="31" id="KbP-M6-N3w"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
|
||||
<state key="normal" title="UPDATE"/>
|
||||
</button>
|
||||
</subviews>
|
||||
</stackView>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="firstBaseline" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="RSR-5W-7tt" userLabel="Release Notes">
|
||||
<rect key="frame" x="0.0" y="79" width="340" height="14.5"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="1000" verticalHuggingPriority="251" text="What's New" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="h1u-nj-qsP">
|
||||
<rect key="frame" x="0.0" y="0.0" width="65" height="13.5"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="65" id="C7Y-nh-TKJ"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="11"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" text="Version Notes" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="rNs-2O-k3V">
|
||||
<rect key="frame" x="75" y="-10" width="265" height="24.5"/>
|
||||
<color key="textColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="13"/>
|
||||
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
||||
</textView>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="H0T-dR-3In" firstAttribute="width" secondItem="57X-Ep-rfq" secondAttribute="width" id="DYI-fa-Egk"/>
|
||||
<constraint firstItem="RSR-5W-7tt" firstAttribute="width" secondItem="57X-Ep-rfq" secondAttribute="width" id="d3x-mH-ODQ"/>
|
||||
</constraints>
|
||||
</stackView>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="QTy-uj-lKA">
|
||||
<rect key="frame" x="323" y="99" width="32" height="14.5"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="14.5" id="pWr-Y1-ZW8"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="13"/>
|
||||
<state key="normal" title="More"/>
|
||||
</button>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="bottom" secondItem="57X-Ep-rfq" secondAttribute="bottom" constant="20" id="ArC-R2-jtc"/>
|
||||
<constraint firstAttribute="trailingMargin" secondItem="QTy-uj-lKA" secondAttribute="trailing" id="Otb-0P-uKP"/>
|
||||
<constraint firstItem="57X-Ep-rfq" firstAttribute="leading" secondItem="mdL-JE-wCe" secondAttribute="leading" constant="20" id="PvV-gg-7us"/>
|
||||
<constraint firstItem="57X-Ep-rfq" firstAttribute="top" secondItem="dmf-hv-bwx" secondAttribute="top" constant="20" id="QHM-k8-g0x"/>
|
||||
<constraint firstItem="QTy-uj-lKA" firstAttribute="bottom" secondItem="rNs-2O-k3V" secondAttribute="bottom" id="mF3-ad-Fl5"/>
|
||||
<constraint firstItem="mdL-JE-wCe" firstAttribute="trailing" secondItem="57X-Ep-rfq" secondAttribute="trailing" constant="15" id="sGL-bx-qIk"/>
|
||||
</constraints>
|
||||
<edgeInsets key="layoutMargins" top="20" left="20" bottom="20" right="20"/>
|
||||
<viewLayoutGuide key="safeArea" id="mdL-JE-wCe"/>
|
||||
</view>
|
||||
</subviews>
|
||||
</view>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstItem="dmf-hv-bwx" firstAttribute="top" secondItem="Kqf-Pv-ca3" secondAttribute="top" id="7yY-05-eHt"/>
|
||||
<constraint firstAttribute="bottom" secondItem="dmf-hv-bwx" secondAttribute="bottom" id="Rrx-0k-6He"/>
|
||||
<constraint firstItem="dmf-hv-bwx" firstAttribute="leading" secondItem="Kqf-Pv-ca3" secondAttribute="leading" id="W0V-sT-tXo"/>
|
||||
<constraint firstAttribute="trailing" secondItem="dmf-hv-bwx" secondAttribute="trailing" id="tgy-Zi-iZF"/>
|
||||
</constraints>
|
||||
<viewLayoutGuide key="safeArea" id="C6r-zO-INg"/>
|
||||
<connections>
|
||||
<outlet property="appIconImageView" destination="jg6-wi-ngb" id="j83-Dl-GT6"/>
|
||||
<outlet property="dateLabel" destination="xaB-Kc-Par" id="mfG-3C-r7j"/>
|
||||
<outlet property="moreButton" destination="QTy-uj-lKA" id="UME-5m-Dqe"/>
|
||||
<outlet property="nameLabel" destination="qmI-m4-Mra" id="LQz-w7-HNb"/>
|
||||
<outlet property="updateButton" destination="OSL-U2-BKa" id="WbI-96-Nel"/>
|
||||
<outlet property="versionDescriptionTextView" destination="rNs-2O-k3V" id="4TC-A3-oxb"/>
|
||||
<outlet property="versionDescriptionTitleLabel" destination="h1u-nj-qsP" id="dnz-Yv-BdY"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="618.39999999999998" y="96.251874062968525"/>
|
||||
</collectionViewCell>
|
||||
</objects>
|
||||
</document>
|
||||
Reference in New Issue
Block a user