mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-09 06:43:25 +01:00
Completely redesigns Browse tab with FeaturedViewController
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
C9EEAA842DA87A88A870053B /* Pods_AltStore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AltStore.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D50107EB2ADF2E1A0069F2A1 /* AddSourceTextFieldCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddSourceTextFieldCell.swift; sourceTree = "<group>"; };
|
||||
D5084CCB2B1EA80100C02160 /* FeaturedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedComponents.swift; sourceTree = "<group>"; };
|
||||
D5151BD82A8FF64300C96F28 /* RefreshAllAppsIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshAllAppsIntent.swift; sourceTree = "<group>"; };
|
||||
D5151BE02A90344300C96F28 /* RefreshAllAppsWidgetIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshAllAppsWidgetIntent.swift; sourceTree = "<group>"; };
|
||||
D5151BE52A90391900C96F28 /* View+AltWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+AltWidget.swift"; sourceTree = "<group>"; };
|
||||
@@ -1038,6 +1041,7 @@
|
||||
D52A2F962ACB40F700BDF8E3 /* Logger+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+AltStore.swift"; sourceTree = "<group>"; };
|
||||
D52B4ABE2AF183F0005991C3 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = "<group>"; };
|
||||
D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = "<group>"; };
|
||||
D52C8F002AFC144C00CA0BDD /* FeaturedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedViewController.swift; sourceTree = "<group>"; };
|
||||
D52C8F022AFC56F000CA0BDD /* StoreCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCategory.swift; sourceTree = "<group>"; };
|
||||
D52E988928D002D30032BE6B /* AltStore 11.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AltStore 11.xcdatamodel"; sourceTree = "<group>"; };
|
||||
D52EF2BD2A0594550096C377 /* AppDetailCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetailCollectionViewController.swift; sourceTree = "<group>"; };
|
||||
@@ -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;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="collection view cell content view" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
@@ -71,7 +72,7 @@
|
||||
</collectionViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="cMN-i4-Bxk" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="1730" y="-17"/>
|
||||
<point key="canvasLocation" x="2730" y="-373"/>
|
||||
</scene>
|
||||
<!--App View Controller-->
|
||||
<scene sceneID="TgT-LO-3Er">
|
||||
@@ -215,7 +216,7 @@
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="C9o-C3-sMK" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="2525.5999999999999" y="-17.541229385307346"/>
|
||||
<point key="canvasLocation" x="2730" y="439"/>
|
||||
</scene>
|
||||
<!--App-->
|
||||
<scene sceneID="CgX-7h-sRI">
|
||||
@@ -489,7 +490,7 @@ World</string>
|
||||
</tableViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dhh-ZN-LoG" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="3302" y="-18"/>
|
||||
<point key="canvasLocation" x="3506" y="437"/>
|
||||
</scene>
|
||||
<!--App Screenshots View Controller-->
|
||||
<scene sceneID="E6k-TI-c4N">
|
||||
@@ -514,7 +515,7 @@ World</string>
|
||||
</collectionViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="np0-Hj-vy7" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="4096.8000000000002" y="-437.18140929535235"/>
|
||||
<point key="canvasLocation" x="4302" y="20"/>
|
||||
</scene>
|
||||
<!--App Detail Collection View Controller-->
|
||||
<scene sceneID="Pcn-h5-5fk">
|
||||
@@ -534,7 +535,7 @@ World</string>
|
||||
</collectionViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="fxm-bB-W29" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="4097" y="-19"/>
|
||||
<point key="canvasLocation" x="4298" y="434"/>
|
||||
</scene>
|
||||
<!--Settings-->
|
||||
<scene sceneID="KlD-j0-ROn">
|
||||
@@ -585,7 +586,7 @@ World</string>
|
||||
</navigationBar>
|
||||
<nil name="viewControllers"/>
|
||||
<connections>
|
||||
<segue destination="e3L-BF-iXp" kind="relationship" relationship="rootViewController" id="EVp-fA-PvU"/>
|
||||
<segue destination="KKu-kI-2kg" kind="relationship" relationship="rootViewController" id="2Dm-Oy-wu0"/>
|
||||
</connections>
|
||||
</navigationController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="OkH-49-O0J" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
@@ -784,7 +785,56 @@ World</string>
|
||||
</collectionViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="kiO-UO-esV" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="1728.8" y="716.49175412293857"/>
|
||||
<point key="canvasLocation" x="1729" y="716"/>
|
||||
</scene>
|
||||
<!--Featured View Controller-->
|
||||
<scene sceneID="1eF-L7-aZz">
|
||||
<objects>
|
||||
<collectionViewController storyboardIdentifier="featuredViewController" id="KKu-kI-2kg" customClass="FeaturedViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" dataMode="prototypes" id="2HL-eH-weG">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<collectionViewFlowLayout key="collectionViewLayout" automaticEstimatedItemSize="YES" minimumLineSpacing="10" minimumInteritemSpacing="10" id="PI1-YC-d4l">
|
||||
<size key="itemSize" width="128" height="128"/>
|
||||
<size key="headerReferenceSize" width="0.0" height="0.0"/>
|
||||
<size key="footerReferenceSize" width="0.0" height="0.0"/>
|
||||
<inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
|
||||
</collectionViewFlowLayout>
|
||||
<cells>
|
||||
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" id="Eo1-84-9m0">
|
||||
<rect key="frame" x="0.0" y="0.0" width="128" height="128"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<collectionViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="4ra-vw-qNw">
|
||||
<rect key="frame" x="0.0" y="0.0" width="128" height="128"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
</collectionViewCellContentView>
|
||||
</collectionViewCell>
|
||||
</cells>
|
||||
<connections>
|
||||
<outlet property="dataSource" destination="KKu-kI-2kg" id="tXR-fi-SxU"/>
|
||||
<outlet property="delegate" destination="KKu-kI-2kg" id="XC4-MP-Zdr"/>
|
||||
</connections>
|
||||
</collectionView>
|
||||
<navigationItem key="navigationItem" id="zft-Mo-I7C"/>
|
||||
<connections>
|
||||
<segue destination="e3L-BF-iXp" kind="show" identifier="showBrowseViewController" destinationCreationSelector="makeBrowseViewController:sender:" id="qDq-A7-sdW"/>
|
||||
<segue destination="177-gr-dJU" kind="show" identifier="showSourceDetails" destinationCreationSelector="makeSourceDetailViewController:sender:" id="dmC-aP-9Hg"/>
|
||||
</connections>
|
||||
</collectionViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="Hwb-Di-x8C" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="1729" y="-19"/>
|
||||
</scene>
|
||||
<!--sourceDetailViewController-->
|
||||
<scene sceneID="nDc-kS-RDF">
|
||||
<objects>
|
||||
<viewControllerPlaceholder storyboardName="Sources" referencedIdentifier="sourceDetailViewController" id="177-gr-dJU" sceneMemberID="viewController">
|
||||
<navigationItem key="navigationItem" id="7hT-A6-bBi"/>
|
||||
</viewControllerPlaceholder>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="bhw-oh-Eeq" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="2730" y="-21"/>
|
||||
</scene>
|
||||
<!--App IDs-->
|
||||
<scene sceneID="kvf-US-rRe">
|
||||
@@ -883,7 +933,7 @@ World</string>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="Lvd-jC-AZO" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
<exit id="eS1-sQ-VUA" userLabel="Exit" sceneMemberID="exit"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="3301.5999999999999" y="715.59220389805103"/>
|
||||
<point key="canvasLocation" x="3506" y="1121"/>
|
||||
</scene>
|
||||
<!--News-->
|
||||
<scene sceneID="BV8-6J-nIv">
|
||||
@@ -921,7 +971,7 @@ World</string>
|
||||
</navigationController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="3LN-mt-qWn" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="2526" y="731"/>
|
||||
<point key="canvasLocation" x="2730" y="1120"/>
|
||||
</scene>
|
||||
<!--Sources-->
|
||||
<scene sceneID="Vzf-tb-LIH">
|
||||
|
||||
100
AltStore/Browse/FeaturedComponents.swift
Normal file
100
AltStore/Browse/FeaturedComponents.swift
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
712
AltStore/Browse/FeaturedViewController.swift
Normal file
712
AltStore/Browse/FeaturedViewController.swift
Normal file
@@ -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<StoreApp, UIImage>
|
||||
{
|
||||
let featuredHeaderDataSource = RSTDynamicCollectionViewDataSource<StoreApp>()
|
||||
featuredHeaderDataSource.numberOfSectionsHandler = { 1 }
|
||||
featuredHeaderDataSource.numberOfItemsHandler = { _ in 0 }
|
||||
|
||||
let dataSource = RSTCompositeCollectionViewPrefetchingDataSource<StoreApp, UIImage>(dataSources: [self.recentlyUpdatedDataSource, self.categoriesDataSource, featuredHeaderDataSource, self.featuredAppsDataSource])
|
||||
dataSource.predicate = StoreApp.visibleAppsPredicate // Ensure we never accidentally show hidden apps
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func makeRecentlyUpdatedDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>
|
||||
{
|
||||
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
|
||||
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<StoreApp, UIImage>(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<StoreApp>
|
||||
{
|
||||
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<StoreApp>(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<StoreApp>(fetchedResultsController: unknownController)
|
||||
unknownDataSource.liveFetchLimit = 1
|
||||
|
||||
// Use composite data source to ensure "Other" category is always last.
|
||||
let dataSource = RSTCompositeCollectionViewDataSource<StoreApp>(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<StoreApp, UIImage>
|
||||
{
|
||||
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
|
||||
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<StoreApp>
|
||||
primaryFetchRequest.predicate = sourceHasRemainingAppsPredicate
|
||||
|
||||
let primaryController = NSFetchedResultsController(fetchRequest: primaryFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(StoreApp.sourceIdentifier), cacheName: nil)
|
||||
let primaryDataSource = RSTFetchedResultsCollectionViewDataSource<StoreApp>(fetchedResultsController: primaryController)
|
||||
primaryDataSource.liveFetchLimit = 5
|
||||
|
||||
let secondaryFetchRequest = fetchRequest.copy() as! NSFetchRequest<StoreApp>
|
||||
secondaryFetchRequest.predicate = NSCompoundPredicate(notPredicateWithSubpredicate: sourceHasRemainingAppsPredicate)
|
||||
|
||||
let secondaryController = NSFetchedResultsController(fetchRequest: secondaryFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(StoreApp.sourceIdentifier), cacheName: nil)
|
||||
let secondaryDataSource = RSTFetchedResultsCollectionViewDataSource<StoreApp>(fetchedResultsController: secondaryController)
|
||||
secondaryDataSource.liveFetchLimit = 5
|
||||
|
||||
// Ensure sources with no remaining apps always come last.
|
||||
let dataSource = RSTCompositeCollectionViewPrefetchingDataSource<StoreApp, UIImage>(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<InstalledApp, Error>)
|
||||
{
|
||||
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
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user