From 36743c0cf4e33dd11bf4b6523c479b9c57994912 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Fri, 8 Dec 2023 14:28:57 -0600 Subject: [PATCH] Completely redesigns Browse tab with FeaturedViewController --- AltStore.xcodeproj/project.pbxproj | 10 + AltStore/Base.lproj/Main.storyboard | 68 +- AltStore/Browse/FeaturedComponents.swift | 100 +++ AltStore/Browse/FeaturedViewController.swift | 712 ++++++++++++++++++ .../AppBannerCollectionViewCell.swift | 1 + .../SourceDetailContentViewController.swift | 1 - 6 files changed, 882 insertions(+), 10 deletions(-) create mode 100644 AltStore/Browse/FeaturedComponents.swift create mode 100644 AltStore/Browse/FeaturedViewController.swift diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 60a855ed..217efd4a 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -341,6 +341,7 @@ 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 */; }; + D5084CCC2B1EA80100C02160 /* FeaturedComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5084CCB2B1EA80100C02160 /* FeaturedComponents.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 */; }; @@ -358,6 +359,7 @@ D52A2F972ACB40F700BDF8E3 /* Logger+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52A2F962ACB40F700BDF8E3 /* Logger+AltStore.swift */; }; D52B4ABF2AF183F0005991C3 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52B4ABE2AF183F0005991C3 /* WebViewController.swift */; }; D52C08EE28AEC37A006C4AE5 /* AppVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */; }; + D52C8F012AFC144C00CA0BDD /* FeaturedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52C8F002AFC144C00CA0BDD /* FeaturedViewController.swift */; }; D52C8F032AFC56F000CA0BDD /* StoreCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52C8F022AFC56F000CA0BDD /* StoreCategory.swift */; }; D52DD35E2AAA89A600A7F2B6 /* AltSign-Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = D52DD35D2AAA89A600A7F2B6 /* AltSign-Dynamic */; }; D52EF2BE2A0594550096C377 /* AppDetailCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52EF2BD2A0594550096C377 /* AppDetailCollectionViewController.swift */; }; @@ -1025,6 +1027,7 @@ D52E988928D002D30032BE6B /* AltStore 11.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AltStore 11.xcdatamodel"; sourceTree = ""; }; 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 = ""; }; + D5084CCB2B1EA80100C02160 /* FeaturedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedComponents.swift; sourceTree = ""; }; D5151BD82A8FF64300C96F28 /* RefreshAllAppsIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshAllAppsIntent.swift; sourceTree = ""; }; D5151BE02A90344300C96F28 /* RefreshAllAppsWidgetIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshAllAppsWidgetIntent.swift; sourceTree = ""; }; D5151BE52A90391900C96F28 /* View+AltWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+AltWidget.swift"; sourceTree = ""; }; @@ -1038,6 +1041,7 @@ D52A2F962ACB40F700BDF8E3 /* Logger+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+AltStore.swift"; sourceTree = ""; }; D52B4ABE2AF183F0005991C3 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; }; D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = ""; }; + D52C8F002AFC144C00CA0BDD /* FeaturedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedViewController.swift; sourceTree = ""; }; D52C8F022AFC56F000CA0BDD /* StoreCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCategory.swift; sourceTree = ""; }; D52E988928D002D30032BE6B /* AltStore 11.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AltStore 11.xcdatamodel"; sourceTree = ""; }; D52EF2BD2A0594550096C377 /* AppDetailCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetailCollectionViewController.swift; sourceTree = ""; }; @@ -1847,6 +1851,8 @@ BF9ABA4322DCFF33008935CF /* Browse */ = { isa = PBXGroup; children = ( + D52C8F002AFC144C00CA0BDD /* FeaturedViewController.swift */, + D5084CCB2B1EA80100C02160 /* FeaturedComponents.swift */, BF9ABA4422DCFF43008935CF /* BrowseViewController.swift */, BF9ABA4822DD0742008935CF /* ScreenshotCollectionViewCell.swift */, ); @@ -3294,6 +3300,7 @@ B39F16152918D7DA002E9404 /* Consts+Proxy.swift in Sources */, BF6C8FAE2429597900125131 /* BannerCollectionViewCell.swift in Sources */, BF6C8FAE2429597900125131 /* AppBannerCollectionViewCell.swift in Sources */, + D5084CCC2B1EA80100C02160 /* FeaturedComponents.swift in Sources */, BF6F439223644C6E00A0B879 /* RefreshAltStoreViewController.swift in Sources */, BFE60742231B07E6002B0E8E /* SettingsHeaderFooterView.swift in Sources */, BD4513AB2C6FA98C0052BCC0 /* AppExtensionView.swift in Sources */, @@ -3362,6 +3369,9 @@ D5F982212AB910180045751F /* AppScreenshotsViewController.swift in Sources */, BFF0B69023219C6D007A79E1 /* PatreonComponents.swift in Sources */, BFBE0004250ACFFB0080826E /* ViewApp.intentdefinition in Sources */, + BF770E5622BC3C03002A40FE /* Server.swift in Sources */, + BFA8172923C56042001B5953 /* ServerConnection.swift in Sources */, + D52C8F012AFC144C00CA0BDD /* FeaturedViewController.swift in Sources */, BF56D2AF23DF9E310006506D /* AppIDsViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/AltStore/Base.lproj/Main.storyboard b/AltStore/Base.lproj/Main.storyboard index d3898e31..6c678982 100644 --- a/AltStore/Base.lproj/Main.storyboard +++ b/AltStore/Base.lproj/Main.storyboard @@ -7,6 +7,7 @@ + @@ -71,7 +72,7 @@ - + @@ -215,7 +216,7 @@ - + @@ -489,7 +490,7 @@ World - + @@ -514,7 +515,7 @@ World - + @@ -534,7 +535,7 @@ World - + @@ -585,7 +586,7 @@ World - + @@ -784,7 +785,56 @@ World - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -883,7 +933,7 @@ World - + @@ -921,7 +971,7 @@ World - + diff --git a/AltStore/Browse/FeaturedComponents.swift b/AltStore/Browse/FeaturedComponents.swift new file mode 100644 index 00000000..79f7f250 --- /dev/null +++ b/AltStore/Browse/FeaturedComponents.swift @@ -0,0 +1,100 @@ +// +// FeaturedComponents.swift +// AltStore +// +// Created by Riley Testut on 12/4/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit + +class LargeIconCollectionViewCell: UICollectionViewCell +{ + let textLabel = UILabel(frame: .zero) + let imageView = UIImageView(frame: .zero) + + override init(frame: CGRect) + { + self.textLabel.translatesAutoresizingMaskIntoConstraints = false + self.textLabel.textColor = .white + self.textLabel.font = .preferredFont(forTextStyle: .headline) + + self.imageView.translatesAutoresizingMaskIntoConstraints = false + self.imageView.contentMode = .center + self.imageView.tintColor = .white + self.imageView.alpha = 0.4 + self.imageView.preferredSymbolConfiguration = .init(pointSize: 80) + + super.init(frame: frame) + + self.contentView.clipsToBounds = true + self.contentView.layer.cornerRadius = 16 + self.contentView.layer.cornerCurve = .continuous + + self.contentView.addSubview(self.textLabel) + self.contentView.addSubview(self.imageView) + + NSLayoutConstraint.activate([ + self.textLabel.leadingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.leadingAnchor, constant: 4), + self.textLabel.bottomAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.bottomAnchor, constant: -4), + + self.imageView.centerXAnchor.constraint(equalTo: self.contentView.trailingAnchor, constant: -30), + self.imageView.centerYAnchor.constraint(equalTo: self.contentView.centerYAnchor, constant: 0), + self.imageView.heightAnchor.constraint(equalTo: self.contentView.heightAnchor, constant: 0), + self.imageView.widthAnchor.constraint(equalTo: self.imageView.heightAnchor) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +class IconButtonCollectionReusableView: UICollectionReusableView +{ + let iconButton: UIButton + let titleButton: UIButton + + private let stackView: UIStackView + + override init(frame: CGRect) + { + let iconHeight = 26.0 + + self.iconButton = UIButton(type: .custom) + self.iconButton.translatesAutoresizingMaskIntoConstraints = false + self.iconButton.clipsToBounds = true + self.iconButton.layer.cornerRadius = iconHeight / 2 + + let content = UIListContentConfiguration.plainHeader() + self.titleButton = UIButton(type: .system) + self.titleButton.translatesAutoresizingMaskIntoConstraints = false + self.titleButton.titleLabel?.font = content.textProperties.font + self.titleButton.setTitleColor(content.textProperties.color, for: .normal) + + self.stackView = UIStackView(arrangedSubviews: [self.iconButton, self.titleButton]) + self.stackView.translatesAutoresizingMaskIntoConstraints = false + self.stackView.axis = .horizontal + self.stackView.alignment = .center + self.stackView.spacing = UIStackView.spacingUseSystem + self.stackView.isLayoutMarginsRelativeArrangement = false + + super.init(frame: frame) + + self.addSubview(self.stackView) + + NSLayoutConstraint.activate([ + self.iconButton.heightAnchor.constraint(equalToConstant: iconHeight), + self.iconButton.widthAnchor.constraint(equalTo: self.iconButton.heightAnchor), + + self.stackView.topAnchor.constraint(equalTo: self.topAnchor), + self.stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + self.stackView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + self.stackView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/AltStore/Browse/FeaturedViewController.swift b/AltStore/Browse/FeaturedViewController.swift new file mode 100644 index 00000000..96a9e4f7 --- /dev/null +++ b/AltStore/Browse/FeaturedViewController.swift @@ -0,0 +1,712 @@ +// +// FeaturedViewController.swift +// AltStore +// +// Created by Riley Testut on 11/8/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit + +import AltStoreCore +import Roxas + +import Nuke + +extension UIAction.Identifier +{ + fileprivate static let showAllApps = Self("io.altstore.ShowAllApps") + fileprivate static let showSourceDetails = Self("io.altstore.ShowSourceDetails") +} + +extension FeaturedViewController +{ + // Open-ended because each Source is its own section + private struct Section: RawRepresentable, Equatable + { + static let recentlyUpdated = Section(rawValue: 0) + static let categories = Section(rawValue: 1) + static let featuredHeader = Section(rawValue: 2) + + let rawValue: Int + + var isFeaturedAppsSection: Bool { + return self.rawValue > Section.featuredHeader.rawValue + } + + init(rawValue: Int) + { + self.rawValue = rawValue + } + } + + private enum ReuseID: String + { + case recent = "RecentCell" + case category = "CategoryCell" + case featuredApp = "FeaturedAppCell" + } + + private enum ElementKind: String + { + case sectionHeader + case sourceHeader + case button + } +} + +class FeaturedViewController: UICollectionViewController +{ + private lazy var dataSource = self.makeDataSource() + private lazy var recentlyUpdatedDataSource = self.makeRecentlyUpdatedDataSource() + private lazy var categoriesDataSource = self.makeCategoriesDataSource() + private lazy var featuredAppsDataSource = self.makeFeaturedAppsDataSource() + + override func viewDidLoad() + { + super.viewDidLoad() + + self.title = NSLocalizedString("Browse", comment: "") + + let layout = Self.makeLayout() + self.collectionView.collectionViewLayout = layout + + self.dataSource.proxy = self + self.collectionView.dataSource = self.dataSource + self.collectionView.prefetchDataSource = self.dataSource + + self.collectionView.register(AppBannerCollectionViewCell.self, forCellWithReuseIdentifier: ReuseID.recent.rawValue) + self.collectionView.register(LargeIconCollectionViewCell.self, forCellWithReuseIdentifier: ReuseID.category.rawValue) + self.collectionView.register(AppCardCollectionViewCell.self, forCellWithReuseIdentifier: ReuseID.featuredApp.rawValue) + + self.collectionView.register(UICollectionViewListCell.self, forSupplementaryViewOfKind: ElementKind.sectionHeader.rawValue, withReuseIdentifier: ElementKind.sectionHeader.rawValue) + self.collectionView.register(IconButtonCollectionReusableView.self, forSupplementaryViewOfKind: ElementKind.sourceHeader.rawValue, withReuseIdentifier: ElementKind.sourceHeader.rawValue) + self.collectionView.register(ButtonCollectionReusableView.self, forSupplementaryViewOfKind: ElementKind.button.rawValue, withReuseIdentifier: ElementKind.button.rawValue) + + self.collectionView.backgroundColor = .altBackground + self.collectionView.directionalLayoutMargins.leading = 20 + self.collectionView.directionalLayoutMargins.trailing = 20 + + self.navigationItem.largeTitleDisplayMode = .always + } +} + +private extension FeaturedViewController +{ + class func makeLayout() -> UICollectionViewCompositionalLayout + { + let config = UICollectionViewCompositionalLayoutConfiguration() + config.interSectionSpacing = 0 // Must be 0 for Section.featuredHeader + config.contentInsetsReference = .layoutMargins + + let layout = UICollectionViewCompositionalLayout(sectionProvider: { (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in + let section = Section(rawValue: sectionIndex) + + let spacing = 10.0 + let interSectionSpacing = 30.0 + let titleSize = NSCollectionLayoutSize(widthDimension: .estimated(100), heightDimension: .estimated(20)) + + switch section + { + case .recentlyUpdated: + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(AppBannerView.standardHeight)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(AppBannerView.standardHeight * 2 + spacing)) + let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item, item]) // 2 items per group + group.interItemSpacing = .fixed(spacing) + + let layoutSection = NSCollectionLayoutSection(group: group) + layoutSection.interGroupSpacing = spacing + layoutSection.orthogonalScrollingBehavior = .groupPagingCentered + layoutSection.contentInsets.bottom = interSectionSpacing + layoutSection.boundarySupplementaryItems = [ + NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.sectionHeader.rawValue, alignment: .topLeading) + ] + return layoutSection + + case .categories: + let itemWidth = (layoutEnvironment.container.effectiveContentSize.width - spacing) / 2 + let itemHeight = 90.0 + let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(itemWidth), heightDimension: .absolute(itemHeight)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(itemHeight)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item, item]) // 2 items per group + group.interItemSpacing = .fixed(spacing) + + let layoutSection = NSCollectionLayoutSection(group: group) + layoutSection.interGroupSpacing = spacing + layoutSection.orthogonalScrollingBehavior = .none + layoutSection.contentInsets.bottom = interSectionSpacing + layoutSection.boundarySupplementaryItems = [ + NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.sectionHeader.rawValue, alignment: .topLeading) + ] + return layoutSection + + case .featuredHeader: + // We don't want to show any items, so set height to 1.0 + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(1.0)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item]) + + let layoutSection = NSCollectionLayoutSection(group: group) + layoutSection.contentInsets.top = 0 + layoutSection.contentInsets.bottom = 0 + layoutSection.boundarySupplementaryItems = [ + NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.sectionHeader.rawValue, alignment: .topLeading) + ] + return layoutSection + + case _ where section.isFeaturedAppsSection: + let itemHeight: NSCollectionLayoutDimension = if #available(iOS 17, *) { .uniformAcrossSiblings(estimate: 350) } else { .estimated(350) } + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: itemHeight) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: itemHeight) + let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) + group.interItemSpacing = .fixed(spacing) + + let titleHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.sourceHeader.rawValue, alignment: .topLeading) + + let buttonSize = NSCollectionLayoutSize(widthDimension: .estimated(44), heightDimension: .estimated(20)) + let buttonHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: buttonSize, elementKind: ElementKind.button.rawValue, alignment: .topTrailing) + + let layoutSection = NSCollectionLayoutSection(group: group) + layoutSection.interGroupSpacing = spacing + layoutSection.orthogonalScrollingBehavior = .groupPagingCentered + layoutSection.contentInsets.top = 8 + layoutSection.contentInsets.bottom = interSectionSpacing + layoutSection.boundarySupplementaryItems = [titleHeader, buttonHeader] + return layoutSection + + default: return nil + } + }, configuration: config) + + return layout + } + + func makeDataSource() -> RSTCompositeCollectionViewPrefetchingDataSource + { + let featuredHeaderDataSource = RSTDynamicCollectionViewDataSource() + featuredHeaderDataSource.numberOfSectionsHandler = { 1 } + featuredHeaderDataSource.numberOfItemsHandler = { _ in 0 } + + let dataSource = RSTCompositeCollectionViewPrefetchingDataSource(dataSources: [self.recentlyUpdatedDataSource, self.categoriesDataSource, featuredHeaderDataSource, self.featuredAppsDataSource]) + dataSource.predicate = StoreApp.visibleAppsPredicate // Ensure we never accidentally show hidden apps + return dataSource + } + + func makeRecentlyUpdatedDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource + { + let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.sortDescriptors = [ + NSSortDescriptor(keyPath: \StoreApp.latestSupportedVersion?.date, ascending: false), + NSSortDescriptor(keyPath: \StoreApp.name, ascending: true), + NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true), + NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true), + ] + + let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext) + dataSource.cellIdentifierHandler = { _ in ReuseID.recent.rawValue } + dataSource.liveFetchLimit = 10 // Show 10 most recently updated apps + dataSource.cellConfigurationHandler = { cell, storeApp, indexPath in + let cell = cell as! AppBannerCollectionViewCell + cell.tintColor = storeApp.tintColor + cell.contentView.preservesSuperviewLayoutMargins = false + cell.contentView.layoutMargins = .zero + + cell.bannerView.button.isIndicatingActivity = false + cell.bannerView.configure(for: storeApp) + + if let versionDate = storeApp.latestSupportedVersion?.date + { + cell.bannerView.subtitleLabel.text = Date().relativeDateString(since: versionDate, dateFormatter: Date.mediumDateFormatter) + } + + cell.bannerView.button.addTarget(self, action: #selector(FeaturedViewController.performAppAction), for: .primaryActionTriggered) + + cell.bannerView.iconImageView.image = nil + cell.bannerView.iconImageView.isIndicatingActivity = true + } + dataSource.prefetchHandler = { (storeApp, indexPath, completion) -> Foundation.Operation? in + return RSTAsyncBlockOperation { (operation) in + storeApp.managedObjectContext?.perform { + ImagePipeline.shared.loadImage(with: storeApp.iconURL, progress: nil) { result in + guard !operation.isCancelled else { return operation.finish() } + + switch result + { + case .success(let response): completion(response.image, nil) + case .failure(let error): completion(nil, error) + } + } + } + } + } + dataSource.prefetchCompletionHandler = { [weak dataSource] (cell, image, indexPath, error) in + let cell = cell as! AppBannerCollectionViewCell + cell.bannerView.iconImageView.image = image + cell.bannerView.iconImageView.isIndicatingActivity = false + + if let error, let dataSource + { + let app = dataSource.item(at: indexPath) + Logger.main.debug("Failed to app icon from \(app.iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)") + } + } + + return dataSource + } + + func makeCategoriesDataSource() -> RSTCompositeCollectionViewDataSource + { + let knownCategories = StoreCategory.allCases.filter { $0 != .other }.map { $0.rawValue } + + let knownFetchRequest = StoreApp.fetchRequest() + knownFetchRequest.predicate = NSPredicate(format: "%K IN %@", #keyPath(StoreApp._category), knownCategories) + knownFetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp._category, ascending: true), + NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true), + NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true)] + + let unknownFetchRequest = StoreApp.fetchRequest() + unknownFetchRequest.predicate = StoreApp.otherCategoryPredicate + unknownFetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp._category, ascending: true), + NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true), + NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true)] + + let knownController = NSFetchedResultsController(fetchRequest: knownFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(StoreApp._category), cacheName: nil) + let knownDataSource = RSTFetchedResultsCollectionViewDataSource(fetchedResultsController: knownController) + knownDataSource.liveFetchLimit = 1 // One app per category + + let unknownController = NSFetchedResultsController(fetchRequest: unknownFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: nil, cacheName: nil) + let unknownDataSource = RSTFetchedResultsCollectionViewDataSource(fetchedResultsController: unknownController) + unknownDataSource.liveFetchLimit = 1 + + // Use composite data source to ensure "Other" category is always last. + let dataSource = RSTCompositeCollectionViewDataSource(dataSources: [knownDataSource, unknownDataSource]) + dataSource.shouldFlattenSections = true // Combine into single section, with one StoreApp per category. + dataSource.cellIdentifierHandler = { _ in ReuseID.category.rawValue } + dataSource.cellConfigurationHandler = { cell, storeApp, indexPath in + let category = storeApp.category ?? .other + + let cell = cell as! LargeIconCollectionViewCell + cell.textLabel.text = category.localizedName + cell.imageView.image = UIImage(systemName: category.symbolName) + + var background = UIBackgroundConfiguration.clear() + background.backgroundColor = category.tintColor + background.cornerRadius = 16 + cell.backgroundConfiguration = background + } + + return dataSource + } + + func makeFeaturedAppsDataSource() -> RSTCompositeCollectionViewPrefetchingDataSource + { + let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.sortDescriptors = [ + // Sort by Source first to group into sections. + NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true), + + // Show uninstalled apps first. + // Sorting by StoreApp.installedApp crashes because InstalledApp does not respond to compare: + // Instead, sort by StoreApp.installedApp.storeApp.source.sourceIdentifier, which will be either nil OR source ID. + NSSortDescriptor(keyPath: \StoreApp.installedApp?.storeApp?.sourceIdentifier, ascending: true), + + // Show featured apps first. + // Sorting by StoreApp.featuringSource crashes because Source does not respond to compare: + // Instead, sort by StoreApp.featuringSource.identifier, which will be either nil OR source ID. + NSSortDescriptor(keyPath: \StoreApp.featuringSource?.identifier, ascending: false), + + // Sort by name. + NSSortDescriptor(keyPath: \StoreApp.name, ascending: true), + + // Sanity check to ensure stable ordering + NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true) + ] + + let sourceHasRemainingAppsPredicate = NSPredicate(format: + """ + SUBQUERY(%K, $app, + ($app.%K != %@) AND ($app.%K == nil) AND (($app.%K == NO) OR ($app.%K == NO) OR ($app.%K == YES)) + ).@count > 0 + """, + #keyPath(StoreApp._source._apps), + #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID, + #keyPath(StoreApp.installedApp), + #keyPath(StoreApp.isPledgeRequired), #keyPath(StoreApp.isHiddenWithoutPledge), #keyPath(StoreApp.isPledged) + ) + + let primaryFetchRequest = fetchRequest.copy() as! NSFetchRequest + primaryFetchRequest.predicate = sourceHasRemainingAppsPredicate + + let primaryController = NSFetchedResultsController(fetchRequest: primaryFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(StoreApp.sourceIdentifier), cacheName: nil) + let primaryDataSource = RSTFetchedResultsCollectionViewDataSource(fetchedResultsController: primaryController) + primaryDataSource.liveFetchLimit = 5 + + let secondaryFetchRequest = fetchRequest.copy() as! NSFetchRequest + secondaryFetchRequest.predicate = NSCompoundPredicate(notPredicateWithSubpredicate: sourceHasRemainingAppsPredicate) + + let secondaryController = NSFetchedResultsController(fetchRequest: secondaryFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(StoreApp.sourceIdentifier), cacheName: nil) + let secondaryDataSource = RSTFetchedResultsCollectionViewDataSource(fetchedResultsController: secondaryController) + secondaryDataSource.liveFetchLimit = 5 + + // Ensure sources with no remaining apps always come last. + let dataSource = RSTCompositeCollectionViewPrefetchingDataSource(dataSources: [primaryDataSource, secondaryDataSource]) + dataSource.cellIdentifierHandler = { _ in ReuseID.featuredApp.rawValue } + dataSource.cellConfigurationHandler = { cell, storeApp, indexPath in + let cell = cell as! AppCardCollectionViewCell + cell.configure(for: storeApp) + + cell.bannerView.button.addTarget(self, action: #selector(FeaturedViewController.performAppAction), for: .primaryActionTriggered) + cell.bannerView.sourceIconImageView.isHidden = true + + cell.bannerView.iconImageView.image = nil + cell.bannerView.iconImageView.isIndicatingActivity = true + } + dataSource.prefetchHandler = { (storeApp, indexPath, completion) -> Foundation.Operation? in + return RSTAsyncBlockOperation { (operation) in + storeApp.managedObjectContext?.perform { + ImagePipeline.shared.loadImage(with: storeApp.iconURL, progress: nil) { result in + guard !operation.isCancelled else { return operation.finish() } + + switch result + { + case .success(let response): completion(response.image, nil) + case .failure(let error): completion(nil, error) + } + } + } + } + } + dataSource.prefetchCompletionHandler = { [weak dataSource] (cell, image, indexPath, error) in + let cell = cell as! AppCardCollectionViewCell + cell.bannerView.iconImageView.image = image + cell.bannerView.iconImageView.isIndicatingActivity = false + + if let error = error, let dataSource + { + let app = dataSource.item(at: indexPath) + Logger.main.debug("Failed to app icon from \(app.iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)") + } + } + + return dataSource + } +} + +private extension FeaturedViewController +{ + @IBSegueAction + func makeBrowseViewController(_ coder: NSCoder, sender: Any) -> UIViewController? + { + if let category = sender as? StoreCategory + { + let browseViewController = BrowseViewController(category: category, coder: coder) + return browseViewController + } + else if let source = sender as? Source + { + let browseViewController = BrowseViewController(source: source, coder: coder) + return browseViewController + } + else + { + let browseViewController = BrowseViewController(coder: coder) + return browseViewController + } + } + + @IBSegueAction + func makeSourceDetailViewController(_ coder: NSCoder, sender: Any?) -> UIViewController? + { + guard let source = sender as? Source else { return nil } + + let sourceDetailViewController = SourceDetailViewController(source: source, coder: coder) + return sourceDetailViewController + } + + func showAllApps(for source: Source) + { + self.performSegue(withIdentifier: "showBrowseViewController", sender: source) + } + + func showSourceDetails(for source: Source) + { + self.performSegue(withIdentifier: "showSourceDetails", sender: source) + } +} + +private extension FeaturedViewController +{ + @objc func performAppAction(_ sender: PillButton) + { + let point = self.collectionView.convert(sender.center, from: sender.superview) + guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return } + + let storeApp = self.dataSource.item(at: indexPath) + + if let installedApp = storeApp.installedApp, !installedApp.isUpdateAvailable + { + self.open(installedApp) + } + else + { + self.install(storeApp, at: indexPath) + } + } + + @objc func install(_ storeApp: StoreApp, at indexPath: IndexPath) + { + let previousProgress = AppManager.shared.installationProgress(for: storeApp) + guard previousProgress == nil else { + previousProgress?.cancel() + return + } + + if let installedApp = storeApp.installedApp, installedApp.isUpdateAvailable + { + AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:)) + } + else + { + AppManager.shared.install(storeApp, presentingViewController: self, completionHandler: finish(_:)) + } + + UIView.performWithoutAnimation { + self.collectionView.reloadItems(at: [indexPath]) + } + + func finish(_ result: Result) + { + DispatchQueue.main.async { + switch result + { + case .failure(OperationError.cancelled): break // Ignore + case .failure(let error): + let toastView = ToastView(error: error) + toastView.opensErrorLog = true + toastView.show(in: self) + + case .success: + Logger.main.info("Installed app \(storeApp.bundleIdentifier, privacy: .public) from FeaturedViewController.") + } + + for indexPath in self.collectionView.indexPathsForVisibleItems + { + // Only need to reload if it's still visible. + + let item = self.dataSource.item(at: indexPath) + guard item == storeApp else { continue } + + UIView.performWithoutAnimation { + self.collectionView.reloadItems(at: [indexPath]) + } + } + } + } + } + + func open(_ installedApp: InstalledApp) + { + UIApplication.shared.open(installedApp.openAppURL) + } +} + +extension FeaturedViewController +{ + override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView + { + let section = Section(rawValue: indexPath.section) + + switch kind + { + case ElementKind.sourceHeader.rawValue: + let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! IconButtonCollectionReusableView + + let indexPath = IndexPath(item: 0, section: indexPath.section) + let storeApp = self.dataSource.item(at: indexPath) + + var content = UIListContentConfiguration.plainHeader() + content.text = storeApp.source?.name ?? NSLocalizedString("Unknown Source", comment: "") + content.textProperties.numberOfLines = 1 + + content.directionalLayoutMargins.leading = 0 + content.imageToTextPadding = 8 + content.imageProperties.reservedLayoutSize = CGSize(width: 26, height: 26) + content.imageProperties.maximumSize = CGSize(width: 26, height: 26) + content.imageProperties.cornerRadius = 13 + + headerView.titleButton.setTitle(content.text, for: .normal) + + headerView.iconButton.backgroundColor = storeApp.source?.effectiveTintColor?.adjustedForDisplay + headerView.iconButton.setImage(nil, for: .normal) + + if let iconURL = storeApp.source?.effectiveIconURL + { + ImagePipeline.shared.loadImage(with: iconURL) { result in + guard case .success(let image) = result else { return } + + headerView.iconButton.backgroundColor = .white + headerView.iconButton.setImage(image.image, for: .normal) + } + } + + let buttons = [headerView.iconButton, headerView.titleButton] + for button in buttons + { + button.removeAction(identifiedBy: .showSourceDetails, for: .primaryActionTriggered) + + if let source = storeApp.source + { + let action = UIAction(identifier: .showSourceDetails) { [weak self] _ in + self?.showSourceDetails(for: source) + } + button.addAction(action, for: .primaryActionTriggered) + } + } + + return headerView + + case ElementKind.sectionHeader.rawValue: + // Regular section header + + let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! UICollectionViewListCell + + var content: UIListContentConfiguration = if #available(iOS 15, *) { + .prominentInsetGroupedHeader() + } + else { + .groupedHeader() + } + + switch section + { + case .recentlyUpdated: content.text = NSLocalizedString("New & Updated", comment: "") + case .categories: content.text = NSLocalizedString("Categories", comment: "") + case .featuredHeader: content.text = NSLocalizedString("Featured", comment: "") + default: break + } + + content.directionalLayoutMargins.leading = .zero + content.directionalLayoutMargins.trailing = .zero + + headerView.contentConfiguration = content + return headerView + + case ElementKind.button.rawValue where section.isFeaturedAppsSection: + let buttonView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! ButtonCollectionReusableView + + let indexPath = IndexPath(item: 0, section: indexPath.section) + let storeApp = self.dataSource.item(at: indexPath) + + buttonView.tintColor = storeApp.source?.effectiveTintColor?.adjustedForDisplay ?? .altPrimary + + buttonView.button.setTitle(NSLocalizedString("See All", comment: ""), for: .normal) + buttonView.button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) + buttonView.button.contentEdgeInsets.bottom = 8 + + buttonView.button.removeAction(identifiedBy: .showAllApps, for: .primaryActionTriggered) + + if let source = storeApp.source + { + let action = UIAction(identifier: .showAllApps) { [weak self] _ in + self?.showAllApps(for: source) + } + buttonView.button.addAction(action, for: .primaryActionTriggered) + } + + return buttonView + + default: return UICollectionReusableView(frame: .zero) + } + } + + override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) + { + let storeApp = self.dataSource.item(at: indexPath) + + let section = Section(rawValue: indexPath.section) + switch section + { + case _ where section.isFeaturedAppsSection: fallthrough + case .recentlyUpdated: + let appViewController = AppViewController.makeAppViewController(app: storeApp) + self.navigationController?.pushViewController(appViewController, animated: true) + + case .categories: + let category = storeApp.category ?? .other + self.performSegue(withIdentifier: "showBrowseViewController", sender: category) + + default: break + } + } +} + +@available(iOS 17, *) +#Preview(traits: .portrait) { + DatabaseManager.shared.startForPreview() + + let storyboard = UIStoryboard(name: "Main", bundle: nil) + let featuredViewController = storyboard.instantiateViewController(identifier: "featuredViewController") + + let navigationController = UINavigationController(rootViewController: featuredViewController) + navigationController.navigationBar.prefersLargeTitles = true + navigationController.modalPresentationStyle = .fullScreen + + let viewController = UIViewController() + + AppManager.shared.fetchSources() { (result) in + do + { + let (_, context) = try result.get() + try context.save() + } + catch let error as NSError + { + Logger.main.error("Failed to fetch sources for preview. \(error.localizedDescription, privacy: .public)") + } + } + + AppManager.shared.updateKnownSources { result in + Task { + do + { + let knownSources = try result.get() + + let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() + + try await withThrowingTaskGroup(of: Void.self) { taskGroup in + for source in knownSources.0 + { + guard let sourceURL = source.sourceURL else { continue } + + taskGroup.addTask { + _ = try await AppManager.shared.fetchSource(sourceURL: sourceURL, managedObjectContext: context) + } + } + } + + await context.performAsync { + try! context.save() + } + + await MainActor.run { + viewController.present(navigationController, animated: true) + } + } + catch + { + Logger.main.error("Failed to fetch known sources for preview. \(error.localizedDescription, privacy: .public)") + } + } + } + + return viewController +} diff --git a/AltStore/Components/AppBannerCollectionViewCell.swift b/AltStore/Components/AppBannerCollectionViewCell.swift index 9e7813c0..f845ddd9 100644 --- a/AltStore/Components/AppBannerCollectionViewCell.swift +++ b/AltStore/Components/AppBannerCollectionViewCell.swift @@ -33,6 +33,7 @@ class AppBannerCollectionViewCell: UICollectionViewListCell self.contentView.insetsLayoutMarginsFromSafeArea = false self.bannerView.insetsLayoutMarginsFromSafeArea = false + self.backgroundView = UIView() // Clear background self.selectedBackgroundView = UIView() // Disable selection highlighting. self.contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight] diff --git a/AltStore/Sources/SourceDetailContentViewController.swift b/AltStore/Sources/SourceDetailContentViewController.swift index a9b696ab..89f2bc81 100644 --- a/AltStore/Sources/SourceDetailContentViewController.swift +++ b/AltStore/Sources/SourceDetailContentViewController.swift @@ -223,7 +223,6 @@ private extension SourceDetailContentViewController // For some reason, setting cell.layoutMargins = .zero does not update cell.contentView.layoutMargins. cell.layoutMargins = .zero cell.contentView.layoutMargins = .zero - cell.contentView.backgroundColor = .altBackground cell.bannerView.button.isIndicatingActivity = false cell.bannerView.configure(for: storeApp, showSourceIcon: false)