Refactors SourceViewController into dedicated tab

* Updates UI to use source icons + tint colors
* Adds Edit button + swipe actions
This commit is contained in:
Riley Testut
2023-10-17 14:49:13 -05:00
committed by Magesh K
parent f9911d285d
commit b03b7bfe68
16 changed files with 517 additions and 546 deletions

View File

@@ -433,6 +433,7 @@
D5F5AF7D28ECEA990067C736 /* ErrorDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F5AF7C28ECEA990067C736 /* ErrorDetailsViewController.swift */; };
D5F99A1828D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = D5F99A1728D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel */; };
D5F99A1A28D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F99A1928D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift */; };
D5FB28EC2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */; };
D5FB28EE2ADDF89800A1C337 /* KnownSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5893F812A141E4900E767CD /* KnownSource.swift */; };
D5FB7A0E2AA25A4E00EF863D /* Previews.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D5FB7A0D2AA25A4E00EF863D /* Previews.xcassets */; };
D5FB7A212AA284ED00EF863D /* EnableJIT.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5FB7A1A2AA284ED00EF863D /* EnableJIT.swift */; };
@@ -1089,6 +1090,7 @@
D5F5AF7C28ECEA990067C736 /* ErrorDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorDetailsViewController.swift; sourceTree = "<group>"; };
D5F99A1728D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = AltStore10ToAltStore11.xcmappingmodel; sourceTree = "<group>"; };
D5F99A1928D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreApp10ToStoreApp11Policy.swift; sourceTree = "<group>"; };
D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFontDescriptor+Bold.swift"; sourceTree = "<group>"; };
D5FB7A0D2AA25A4E00EF863D /* Previews.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Previews.xcassets; sourceTree = "<group>"; };
D5FB7A132AA284BE00EF863D /* altjit */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = altjit; sourceTree = BUILT_PRODUCTS_DIR; };
D5FB7A1A2AA284ED00EF863D /* EnableJIT.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = EnableJIT.swift; path = AltJIT/Commands/EnableJIT.swift; sourceTree = SOURCE_ROOT; };
@@ -2007,6 +2009,7 @@
0E13E5852CC8F55900E9C0DF /* ProcessInfo+SideStore.swift */,
D5927D6529DCC89000D6898E /* UINavigationBarAppearance+TintColor.swift */,
D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */,
D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */,
);
path = Extensions;
sourceTree = "<group>";
@@ -3151,6 +3154,7 @@
BFB6B21E231870160022A802 /* NewsViewController.swift in Sources */,
BFC57A652416C72400EB891E /* DeactivateAppOperation.swift in Sources */,
D5CD805D29CA2C1E00E591B0 /* HeaderContentViewController.swift in Sources */,
D5FB28EC2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift in Sources */,
BF3BEFC124086A1E00DE7D55 /* RefreshAppOperation.swift in Sources */,
BFE60740231AFD2A002B0E8E /* InsetGroupTableViewCell.swift in Sources */,
BF0DCA662433BDF500E3A595 /* AnalyticsManager.swift in Sources */,

View File

@@ -36,11 +36,11 @@
</tabBar>
<connections>
<segue destination="kjR-gi-fgT" kind="relationship" relationship="viewControllers" id="eWy-uk-nwG"/>
<segue destination="faz-B4-Sub" kind="relationship" relationship="viewControllers" id="dXz-Tu-hW8"/>
<segue destination="3Ew-ox-i4n" kind="relationship" relationship="viewControllers" id="zii-dF-qEt"/>
<segue destination="p3d-dP-Swg" kind="relationship" relationship="viewControllers" id="4Nf-rL-P4c"/>
<segue destination="bTL-bY-9Yq" kind="presentation" identifier="finishJailbreak" id="cIc-Ta-uNk"/>
<segue destination="HCK-G6-KdY" kind="presentation" identifier="presentSources" id="SRd-VL-5nP"/>
<segue destination="HCK-G6-KdY" kind="relationship" relationship="viewControllers" id="X0t-T6-JeA"/>
<segue destination="faz-B4-Sub" kind="relationship" relationship="viewControllers" id="OLu-kM-z1J"/>
<segue destination="3Ew-ox-i4n" kind="relationship" relationship="viewControllers" id="phQ-Pc-pqw"/>
<segue destination="p3d-dP-Swg" kind="relationship" relationship="viewControllers" id="cQE-Az-fdo"/>
</connections>
</tabBarController>
<placeholder placeholderIdentifier="IBFirstResponder" id="HuB-VB-40B" sceneMemberID="firstResponder"/>
@@ -67,16 +67,7 @@
<outlet property="delegate" destination="e3L-BF-iXp" id="Mdp-x4-hZe"/>
</connections>
</collectionView>
<navigationItem key="navigationItem" title="Browse" id="pUx-6k-rtr">
<barButtonItem key="rightBarButtonItem" title="Sources" id="6Ul-JW-TMT">
<connections>
<segue destination="HCK-G6-KdY" kind="presentation" id="hBK-Tt-naZ"/>
</connections>
</barButtonItem>
</navigationItem>
<connections>
<outlet property="sourcesBarButtonItem" destination="6Ul-JW-TMT" id="99s-O4-OpX"/>
</connections>
<navigationItem key="navigationItem" title="Browse" id="pUx-6k-rtr"/>
</collectionViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="cMN-i4-Bxk" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
@@ -921,20 +912,20 @@ World</string>
<!--Sources-->
<scene sceneID="Vzf-tb-LIH">
<objects>
<viewControllerPlaceholder storyboardName="Sources" id="HCK-G6-KdY" sceneMemberID="viewController"/>
<viewControllerPlaceholder storyboardName="Sources" id="HCK-G6-KdY" sceneMemberID="viewController">
<tabBarItem key="tabBarItem" title="Item" id="Q7y-bi-ncT"/>
</viewControllerPlaceholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="VTd-he-VYb" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-2" y="-553"/>
</scene>
</scenes>
<inferredMetricsTieBreakers>
<segue reference="hBK-Tt-naZ"/>
<segue reference="cnd-KK-o60"/>
</inferredMetricsTieBreakers>
<color key="tintColor" name="Primary"/>
<resources>
<image name="Back" width="18" height="18"/>
<image name="Browse" width="20" height="20"/>
<image name="MyApps" width="20" height="20"/>
<image name="News" width="19" height="20"/>
<image name="Settings" width="20" height="20"/>

View File

@@ -92,11 +92,6 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing
self.update()
}
@IBAction private func unwindFromSourcesViewController(_ segue: UIStoryboardSegue)
{
self.fetchSource()
}
}
private extension BrowseViewController

View File

@@ -8,12 +8,10 @@
import UIKit
final class BannerCollectionViewCell: UICollectionViewCell
class AppBannerCollectionViewCell: UICollectionViewListCell
{
let bannerView = AppBannerView(frame: .zero)
private(set) var errorBadge: UIView!
override init(frame: CGRect)
{
super.init(frame: frame)
@@ -30,43 +28,24 @@ final class BannerCollectionViewCell: UICollectionViewCell
private func initialize()
{
// Prevent content "squishing" when scrolling offscreen.
self.insetsLayoutMarginsFromSafeArea = false
self.contentView.insetsLayoutMarginsFromSafeArea = false
self.bannerView.insetsLayoutMarginsFromSafeArea = false
self.selectedBackgroundView = UIView() // Disable selection highlighting.
self.contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.contentView.preservesSuperviewLayoutMargins = true
self.bannerView.translatesAutoresizingMaskIntoConstraints = false
self.contentView.addSubview(self.bannerView)
let errorBadge = UIView()
errorBadge.translatesAutoresizingMaskIntoConstraints = false
errorBadge.isHidden = true
self.addSubview(errorBadge)
// Solid background to make the X opaque white.
let backgroundView = UIView()
backgroundView.translatesAutoresizingMaskIntoConstraints = false
backgroundView.backgroundColor = .white
errorBadge.addSubview(backgroundView)
let badgeView = UIImageView(image: UIImage(systemName: "exclamationmark.circle.fill"))
badgeView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(scale: .large)
badgeView.tintColor = .systemRed
errorBadge.addSubview(badgeView, pinningEdgesWith: .zero)
NSLayoutConstraint.activate([
self.bannerView.topAnchor.constraint(equalTo: self.contentView.topAnchor),
self.bannerView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor),
self.bannerView.topAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.topAnchor),
self.bannerView.bottomAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.bottomAnchor),
self.bannerView.leadingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.leadingAnchor),
self.bannerView.trailingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.trailingAnchor),
errorBadge.centerXAnchor.constraint(equalTo: self.bannerView.trailingAnchor, constant: -5),
errorBadge.centerYAnchor.constraint(equalTo: self.bannerView.topAnchor, constant: 5),
backgroundView.centerXAnchor.constraint(equalTo: badgeView.centerXAnchor),
backgroundView.centerYAnchor.constraint(equalTo: badgeView.centerYAnchor),
backgroundView.widthAnchor.constraint(equalTo: badgeView.widthAnchor, multiplier: 0.5),
backgroundView.heightAnchor.constraint(equalTo: badgeView.heightAnchor, multiplier: 0.5)
])
self.errorBadge = errorBadge
}
}

View File

@@ -132,6 +132,8 @@ extension AppBannerView
}
}
self.style = .app
let values = AppValues(app: app)
self.titleLabel.text = app.name // Don't use values.name since that already includes "beta".
self.betaBadgeView.isHidden = !values.isBeta
@@ -147,6 +149,22 @@ extension AppBannerView
self.accessibilityLabel = values.name
}
}
func configure(for source: Source)
{
self.style = .source
self.titleLabel.text = source.name
let subtitle = source.subtitle ?? source.sourceURL.absoluteString
self.subtitleLabel.text = subtitle
let tintColor = source.effectiveTintColor ?? .altPrimary
self.tintColor = tintColor
let accessibilityLabel = source.name + "\n" + subtitle
self.accessibilityLabel = accessibilityLabel
}
}
private extension AppBannerView
@@ -162,20 +180,28 @@ private extension AppBannerView
switch self.style
{
case .app:
self.directionalLayoutMargins.trailing = self.stackView.directionalLayoutMargins.trailing
self.iconImageViewHeightConstraint.constant = 60
self.iconImageView.style = .icon
self.titleLabel.textColor = .label
self.button.style = .pill
self.backgroundEffectView.contentView.backgroundColor = UIColor(resource: .blurTint)
self.backgroundEffectView.backgroundColor = tintColor
case .source:
self.directionalLayoutMargins.trailing = 20
self.iconImageViewHeightConstraint.constant = 44
self.iconImageView.style = .circular
self.titleLabel.textColor = .white
self.button.style = .custom
self.backgroundEffectView.contentView.backgroundColor = tintColor?.adjustedForDisplay
self.backgroundEffectView.backgroundColor = nil

View File

@@ -0,0 +1,18 @@
//
// UIFontDescriptor+Bold.swift
// AltStore
//
// Created by Riley Testut on 10/16/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import UIKit
extension UIFontDescriptor
{
func bolded() -> UIFontDescriptor
{
guard let descriptor = self.withSymbolicTraits(.traitBold) else { return self }
return descriptor
}
}

View File

@@ -133,11 +133,6 @@ class NewsViewController: UICollectionViewController, PeekPopPreviewing
self.collectionView.contentInset.bottom = 20
}
}
@IBAction private func unwindFromSourcesViewController(_ segue: UIStoryboardSegue)
{
self.fetchSource()
}
}
private extension NewsViewController

View File

@@ -1,6 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,150 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="7We-99-yEv">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="22154" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="7We-99-yEv">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22130"/>
<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>
<!--Forwarding Navigation Controller-->
<!--Sources-->
<scene sceneID="QxB-Dd-1xC">
<objects>
<navigationController automaticallyAdjustsScrollViewInsets="NO" id="7We-99-yEv" customClass="ForwardingNavigationController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<tabBarItem key="tabBarItem" title="Sources" image="Sources" id="xPv-dc-X4v"/>
<toolbarItems/>
<simulatedTabBarMetrics key="simulatedBottomBarMetrics"/>
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" largeTitles="YES" id="xWh-1U-u0q" customClass="NavigationBar" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="59" width="393" height="96"/>
<autoresizingMask key="autoresizingMask"/>
</navigationBar>
<nil name="viewControllers"/>
<connections>
<segue destination="5vR-Su-j54" kind="relationship" relationship="rootViewController" id="Kt6-Nl-WsS"/>
<segue destination="Wm7-1O-FkD" kind="relationship" relationship="rootViewController" id="B9x-Lz-fg2"/>
</connections>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="ULL-gB-Cpt" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-18" y="-13"/>
</scene>
<!--Sources-->
<scene sceneID="hR0-Xj-lcc">
<objects>
<collectionViewController title="Sources" id="5vR-Su-j54" customClass="SourcesViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" alwaysBounceVertical="YES" dataMode="prototypes" id="M2N-lD-Q3M">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="15" minimumInteritemSpacing="10" id="ldV-TM-Jx1">
<size key="itemSize" width="375" height="80"/>
<size key="headerReferenceSize" width="50" height="200"/>
<size key="footerReferenceSize" width="50" height="50"/>
<inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
</collectionViewFlowLayout>
<cells>
<collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" reuseIdentifier="Cell" id="mkn-CU-TaQ" customClass="AppBannerCollectionViewCell" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="9" y="200" width="375" height="80"/>
<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="80"/>
<autoresizingMask key="autoresizingMask"/>
</view>
</collectionViewCell>
</cells>
<collectionReusableView key="sectionHeaderView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="Header" id="w5e-xs-D45" customClass="TextCollectionReusableView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="393" height="200"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Manage sources to control which apps are available to download through AltStore." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="KCm-fD-Jy0">
<rect key="frame" x="8" y="14" width="377" height="171"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleCallout"/>
<color key="textColor" white="0.5" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstAttribute="bottom" secondItem="KCm-fD-Jy0" secondAttribute="bottom" priority="999" constant="15" id="1M7-ad-U2f"/>
<constraint firstItem="KCm-fD-Jy0" firstAttribute="top" secondItem="w5e-xs-D45" secondAttribute="top" priority="999" constant="14" id="5h0-b0-UWE"/>
<constraint firstAttribute="trailingMargin" secondItem="KCm-fD-Jy0" secondAttribute="trailing" priority="999" id="K04-Ud-iGz"/>
<constraint firstAttribute="leadingMargin" secondItem="KCm-fD-Jy0" secondAttribute="leading" priority="999" id="MN6-lr-3tF"/>
</constraints>
<connections>
<outlet property="bottomLayoutConstraint" destination="1M7-ad-U2f" id="tej-O7-Lyh"/>
<outlet property="leadingLayoutConstraint" destination="MN6-lr-3tF" id="Deq-Tk-7Lc"/>
<outlet property="textLabel" destination="KCm-fD-Jy0" id="alm-sb-NAa"/>
<outlet property="topLayoutConstraint" destination="5h0-b0-UWE" id="kHq-up-pCk"/>
<outlet property="trailingLayoutConstraint" destination="K04-Ud-iGz" id="rS5-c5-EkL"/>
</connections>
</collectionReusableView>
<collectionReusableView key="sectionFooterView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="Footer" id="H5R-Ci-5aX" customClass="SourcesFooterView">
<rect key="frame" x="0.0" y="280" width="393" height="50"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="15" translatesAutoresizingMaskIntoConstraints="NO" id="oeg-V0-knE">
<rect key="frame" x="8" y="0.0" width="377" height="50"/>
<subviews>
<activityIndicatorView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" hidesWhenStopped="YES" animating="YES" style="medium" translatesAutoresizingMaskIntoConstraints="NO" id="hnb-2l-24w">
<rect key="frame" x="0.0" y="0.0" width="377" height="0.0"/>
</activityIndicatorView>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="800" scrollEnabled="NO" contentInsetAdjustmentBehavior="never" editable="NO" text="Test" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="4Mf-Ge-exp">
<rect key="frame" x="0.0" y="15" width="377" height="35"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<color key="textColor" white="0.5" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
</subviews>
</stackView>
</subviews>
<constraints>
<constraint firstItem="oeg-V0-knE" firstAttribute="top" secondItem="H5R-Ci-5aX" secondAttribute="top" priority="999" id="KK7-2G-rL0" propertyAccessControl="none"/>
<constraint firstAttribute="trailingMargin" secondItem="oeg-V0-knE" secondAttribute="trailing" priority="999" id="aG4-4x-ACP"/>
<constraint firstAttribute="bottom" secondItem="oeg-V0-knE" secondAttribute="bottom" priority="999" id="ueD-zU-eSQ"/>
<constraint firstItem="oeg-V0-knE" firstAttribute="leading" secondItem="H5R-Ci-5aX" secondAttribute="leadingMargin" priority="999" id="w2y-e8-rJG"/>
</constraints>
<connections>
<outlet property="activityIndicatorView" destination="hnb-2l-24w" id="6Dp-gp-nlC"/>
<outlet property="bottomLayoutConstraint" destination="ueD-zU-eSQ" id="ChX-d2-sRT"/>
<outlet property="leadingLayoutConstraint" destination="w2y-e8-rJG" id="9j3-ao-bfA"/>
<outlet property="textView" destination="4Mf-Ge-exp" id="XqS-CB-3ek"/>
<outlet property="topLayoutConstraint" destination="KK7-2G-rL0" id="oFe-fV-BgO"/>
<outlet property="trailingLayoutConstraint" destination="aG4-4x-ACP" id="YOc-HZ-1pw"/>
</connections>
</collectionReusableView>
<connections>
<outlet property="dataSource" destination="5vR-Su-j54" id="lGW-bH-xYZ"/>
<outlet property="delegate" destination="5vR-Su-j54" id="bj3-kR-erl"/>
</connections>
</collectionView>
<navigationItem key="navigationItem" title="Sources" id="UQX-GH-OMC">
<barButtonItem key="leftBarButtonItem" systemItem="add" id="ox5-Bu-RLr">
<connections>
<action selector="addSource" destination="5vR-Su-j54" id="4s7-KQ-ume"/>
</connections>
</barButtonItem>
<barButtonItem key="rightBarButtonItem" style="done" systemItem="done" id="QYt-Dn-SKf">
<connections>
<segue destination="wBZ-c2-miy" kind="unwind" unwindAction="unwindFromSourcesViewController:" id="Dc6-tD-Z2I"/>
</connections>
</barButtonItem>
</navigationItem>
<connections>
<segue destination="7XE-Wv-lf9" kind="show" identifier="showSourceDetails" destinationCreationSelector="makeSourceDetailViewController:sender:" id="dLj-Tf-ZjV"/>
</connections>
</collectionViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="69z-hg-xF8" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
<exit id="wBZ-c2-miy" userLabel="Exit" sceneMemberID="exit"/>
</objects>
<point key="canvasLocation" x="810" y="-13"/>
</scene>
<!--All Apps-->
<scene sceneID="d8a-U8-CPc">
<objects>
<placeholder placeholderIdentifier="IBFirstResponder" id="ih5-9R-QX7" userLabel="First Responder" sceneMemberID="firstResponder"/>
<collectionViewController storyboardIdentifier="browseViewController" id="Nhf-Gw-Ukx" customClass="BrowseViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" dataMode="prototypes" id="oBI-6P-Lm3">
<rect key="frame" x="0.0" y="0.0" width="393" height="783"/>
<rect key="frame" x="0.0" y="0.0" width="393" height="842"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="50" minimumInteritemSpacing="10" id="pSh-Xl-aNg">
@@ -161,13 +52,13 @@
</collectionView>
<navigationItem key="navigationItem" title="All Apps" id="rUb-ON-AON"/>
</collectionViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="ih5-9R-QX7" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="3404" y="-13"/>
</scene>
<!--Source Detail View Controller-->
<scene sceneID="xbN-Mz-TtU">
<objects>
<placeholder placeholderIdentifier="IBFirstResponder" id="cYc-NX-nF1" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
<viewController storyboardIdentifier="sourceDetailViewController" id="7XE-Wv-lf9" customClass="SourceDetailViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="eIv-0H-ZIq">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
@@ -177,13 +68,13 @@
</view>
<navigationItem key="navigationItem" id="Ocv-bj-TfG"/>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="cYc-NX-nF1" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="1646.5648854961833" y="-13.380281690140846"/>
</scene>
<!--Source Detail Content View Controller-->
<scene sceneID="8nl-ly-jhT">
<objects>
<placeholder placeholderIdentifier="IBFirstResponder" id="KEz-hK-u3f" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
<collectionViewController storyboardIdentifier="sourceDetailContentViewController" id="MSh-hM-32I" customClass="SourceDetailContentViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" dataMode="prototypes" id="fiF-YD-Ing">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
@@ -223,16 +114,16 @@
<segue destination="Nhf-Gw-Ukx" kind="show" identifier="showAllApps" destinationCreationSelector="makeBrowseViewController:" id="On0-GP-kaE"/>
</connections>
</collectionViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="KEz-hK-u3f" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="2509" y="-13"/>
</scene>
<!--All News-->
<scene sceneID="avV-5f-uNE">
<objects>
<placeholder placeholderIdentifier="IBFirstResponder" id="7f5-vn-JrS" userLabel="First Responder" sceneMemberID="firstResponder"/>
<collectionViewController storyboardIdentifier="newsViewController" id="MVH-oB-c8m" customClass="NewsViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" id="p9p-rr-fbF">
<rect key="frame" x="0.0" y="0.0" width="393" height="783"/>
<rect key="frame" x="0.0" y="0.0" width="393" height="842"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="40" minimumInteritemSpacing="40" id="3cD-ax-3h6">
@@ -249,11 +140,53 @@
</collectionView>
<navigationItem key="navigationItem" title="All News" id="FGB-cd-Vkd"/>
</collectionViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="7f5-vn-JrS" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="3404" y="-711"/>
</scene>
<!--Sources-->
<scene sceneID="w2v-ek-6dY">
<objects>
<collectionViewController storyboardIdentifier="sourcesViewController" title="Sources" id="Wm7-1O-FkD" customClass="SourcesViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" alwaysBounceVertical="YES" dataMode="prototypes" id="VJr-nx-7Vh">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="15" minimumInteritemSpacing="10" id="Rlv-wJ-9Ef">
<size key="itemSize" width="375" height="80"/>
<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" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" reuseIdentifier="Cell" id="1Rm-Gf-VDt" customClass="AppBannerCollectionViewCell" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="9" y="0.0" width="375" height="80"/>
<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="80"/>
<autoresizingMask key="autoresizingMask"/>
</view>
</collectionViewCell>
</cells>
<connections>
<outlet property="dataSource" destination="Wm7-1O-FkD" id="239-Tq-YUw"/>
<outlet property="delegate" destination="Wm7-1O-FkD" id="UvE-ta-8ir"/>
</connections>
</collectionView>
<navigationItem key="navigationItem" title="Sources" largeTitleDisplayMode="always" id="Noh-fc-wch">
<barButtonItem key="leftBarButtonItem" systemItem="add" id="y96-Ve-1gW"/>
</navigationItem>
<connections>
<segue destination="7XE-Wv-lf9" kind="show" identifier="showSourceDetails" destinationCreationSelector="makeSourceDetailViewController:sender:" id="eVl-NI-lj3"/>
</connections>
</collectionViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="cR1-aE-0KX" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="810" y="-13"/>
</scene>
</scenes>
<resources>
<image name="Sources" width="20" height="20"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>

View File

@@ -11,6 +11,7 @@ import CoreData
import AltStoreCore
import Roxas
import Nuke
struct SourceError: ALTLocalizedError
{
@@ -40,151 +41,170 @@ private final class SourcesFooterView: TextCollectionReusableView
@IBOutlet var textView: UITextView!
}
extension SourcesViewController
private extension UIAction.Identifier
{
private enum Section: Int, CaseIterable
{
case added
case trusted
}
static let showDetails = UIAction.Identifier("io.altstore.showDetails")
static let showError = UIAction.Identifier("io.altstore.showError")
}
final class SourcesViewController: UICollectionViewController
{
var deepLinkSourceURL: URL? {
didSet {
guard let sourceURL = self.deepLinkSourceURL else { return }
self.addSource(url: sourceURL)
self.handleAddSourceDeepLink()
}
}
private lazy var dataSource = self.makeDataSource()
private lazy var addedSourcesDataSource = self.makeAddedSourcesDataSource()
private lazy var trustedSourcesDataSource = self.makeTrustedSourcesDataSource()
private var fetchTrustedSourcesOperation: UpdateKnownSourcesOperation?
private var fetchTrustedSourcesResult: Result<Void, Error>?
private var _fetchTrustedSourcesContext: NSManagedObjectContext?
private lazy var dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .short
dateFormatter.timeStyle = .none
return dateFormatter
}()
private var placeholderView: RSTPlaceholderView!
private var placeholderViewButton: UIButton!
private var placeholderViewCenterYConstraint: NSLayoutConstraint!
override func viewDidLoad()
{
super.viewDidLoad()
self.view.tintColor = .altPrimary
let layout = self.makeLayout()
self.collectionView.collectionViewLayout = layout
self.navigationController?.view.tintColor = .altPrimary
if let navigationBar = self.navigationController?.navigationBar as? NavigationBar
{
// Don't automatically adjust item positions when being presented non-full screen,
// or else the navigation bar content won't be vertically centered.
navigationBar.automaticallyAdjustsItemPositions = false
}
self.collectionView.register(AppBannerCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
self.collectionView.register(UICollectionViewListCell.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: UICollectionView.elementKindSectionHeader)
self.collectionView.dataSource = self.dataSource
self.collectionView.prefetchDataSource = self.dataSource
self.collectionView.allowsSelectionDuringEditing = false
#if !BETA
// Hide "Add Source" button for public version while in beta.
self.navigationItem.leftBarButtonItem = nil
#endif
}
let backgroundView = UIView(frame: .zero)
backgroundView.backgroundColor = .altBackground
self.collectionView.backgroundView = backgroundView
override func viewWillAppear(_ animated: Bool)
{
super.viewWillAppear(animated)
self.placeholderView = RSTPlaceholderView(frame: .zero)
self.placeholderView.translatesAutoresizingMaskIntoConstraints = false
self.placeholderView.textLabel.text = NSLocalizedString("Add More Sources!", comment: "")
self.placeholderView.detailTextLabel.text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis massa tortor, tempor vel est vitae, consequat luctus arcu."
backgroundView.addSubview(self.placeholderView)
if self.deepLinkSourceURL != nil
{
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = true
}
let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .title3).bolded()
self.placeholderView.textLabel.font = UIFont(descriptor: fontDescriptor, size: 0.0)
self.placeholderView.detailTextLabel.font = UIFont.preferredFont(forTextStyle: .body)
self.placeholderView.detailTextLabel.textAlignment = .natural
if self.fetchTrustedSourcesOperation == nil
{
self.fetchTrustedSources()
}
self.placeholderViewButton = UIButton(type: .system, primaryAction: UIAction(title: NSLocalizedString("View Recommended Sources", comment: "")) { [weak self] _ in
self?.performSegue(withIdentifier: "addSource", sender: nil)
})
self.placeholderViewButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
self.placeholderView.stackView.spacing = 15
self.placeholderView.stackView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 15, leading: 15, bottom: 15, trailing: 15)
self.placeholderView.stackView.isLayoutMarginsRelativeArrangement = true
self.placeholderView.stackView.addArrangedSubview(self.placeholderViewButton)
self.placeholderViewCenterYConstraint = self.placeholderView.safeAreaLayoutGuide.centerYAnchor.constraint(equalTo: backgroundView.centerYAnchor, constant: 0)
NSLayoutConstraint.activate([
self.placeholderViewCenterYConstraint,
self.placeholderView.centerXAnchor.constraint(equalTo: backgroundView.centerXAnchor),
self.placeholderView.leadingAnchor.constraint(equalTo: backgroundView.leadingAnchor),
self.placeholderView.trailingAnchor.constraint(equalTo: backgroundView.trailingAnchor),
self.placeholderView.leadingAnchor.constraint(equalTo: self.placeholderView.stackView.leadingAnchor),
self.placeholderView.trailingAnchor.constraint(equalTo: self.placeholderView.stackView.trailingAnchor),
self.placeholderView.topAnchor.constraint(equalTo: self.placeholderView.stackView.topAnchor),
self.placeholderView.bottomAnchor.constraint(equalTo: self.placeholderView.stackView.bottomAnchor),
])
self.navigationItem.rightBarButtonItem = self.editButtonItem
self.update()
}
override func viewDidAppear(_ animated: Bool)
{
super.viewDidAppear(animated)
if let sourceURL = self.deepLinkSourceURL
self.handleAddSourceDeepLink()
}
override func viewDidLayoutSubviews()
{
self.addSource(url: sourceURL)
super.viewDidLayoutSubviews()
// Vertically center placeholder view in gap below first item.
let indexPath = IndexPath(item: 0, section: 0)
guard let layoutAttributes = self.collectionView.layoutAttributesForItem(at: indexPath) else { return }
let maxY = layoutAttributes.frame.maxY
let constant = maxY / 2
if self.placeholderViewCenterYConstraint.constant != constant
{
self.placeholderViewCenterYConstraint.constant = constant
}
}
}
private extension SourcesViewController
{
func makeDataSource() -> RSTCompositeCollectionViewDataSource<Source>
func makeLayout() -> UICollectionViewCompositionalLayout
{
let dataSource = RSTCompositeCollectionViewDataSource<Source>(dataSources: [self.addedSourcesDataSource, self.trustedSourcesDataSource])
dataSource.proxy = self
dataSource.cellConfigurationHandler = { [weak self] (cell, source, indexPath) in
guard let self else { return }
var configuration = UICollectionLayoutListConfiguration(appearance: .grouped)
configuration.headerMode = .supplementary
configuration.showsSeparators = false
configuration.backgroundColor = .clear
let tintColor = UIColor.altPrimary
configuration.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in
guard let self else { return UISwipeActionsConfiguration(actions: []) }
let cell = cell as! AppBannerCollectionViewCell
cell.layoutMargins.left = self.view.layoutMargins.left
cell.layoutMargins.right = self.view.layoutMargins.right
cell.tintColor = tintColor
let source = self.dataSource.item(at: indexPath)
var actions: [UIContextualAction] = []
cell.bannerView.iconImageView.isHidden = true
cell.bannerView.buttonLabel.isHidden = true
cell.bannerView.button.isIndicatingActivity = false
switch Section.allCases[indexPath.section]
if source.identifier != Source.altStoreIdentifier
{
case .added:
cell.bannerView.button.isHidden = true
// Prevent users from removing AltStore source.
case .trusted:
// Quicker way to determine whether a source is already added than by reading from disk.
if (self.addedSourcesDataSource.fetchedResultsController.fetchedObjects ?? []).contains(where: { $0.identifier == source.identifier })
let removeAction = UIContextualAction(style: .destructive,
title: NSLocalizedString("Remove", comment: "")) { _, _, completion in
self.remove(source, completionHandler: completion)
}
removeAction.image = UIImage(systemName: "trash.fill")
actions.append(removeAction)
}
if let error = source.error
{
// Source exists in .added section, so hide the button.
cell.bannerView.button.isHidden = true
let configuation = UIImage.SymbolConfiguration(pointSize: 24)
let imageAttachment = NSTextAttachment()
imageAttachment.image = UIImage(systemName: "checkmark.circle", withConfiguration: configuation)?.withTintColor(.altPrimary)
let attributedText = NSAttributedString(attachment: imageAttachment)
cell.bannerView.buttonLabel.attributedText = attributedText
cell.bannerView.buttonLabel.textAlignment = .center
cell.bannerView.buttonLabel.isHidden = false
let viewErrorAction = UIContextualAction(style: .normal,
title: NSLocalizedString("View Error", comment: "")) { _, _, completion in
self.present(error)
completion(true)
}
else
{
// Source does not exist in .added section, so show the button.
cell.bannerView.button.isHidden = false
cell.bannerView.buttonLabel.attributedText = nil
viewErrorAction.backgroundColor = .systemYellow
viewErrorAction.image = UIImage(systemName: "exclamationmark.circle.fill")
actions.append(viewErrorAction)
}
cell.bannerView.button.setTitle(NSLocalizedString("ADD", comment: ""), for: .normal)
cell.bannerView.button.addTarget(self, action: #selector(SourcesViewController.addTrustedSource(_:)), for: .primaryActionTriggered)
let config = UISwipeActionsConfiguration(actions: actions)
config.performsFirstActionWithFullSwipe = false
return config
}
cell.bannerView.titleLabel.text = source.name
cell.bannerView.subtitleLabel.text = source.sourceURL.absoluteString
cell.bannerView.subtitleLabel.numberOfLines = 2
cell.errorBadge?.isHidden = (source.error == nil)
let attributedLabel = NSAttributedString(string: source.name + "\n" + source.sourceURL.absoluteString, attributes: [.accessibilitySpeechPunctuation: true])
cell.bannerView.accessibilityAttributedLabel = attributedLabel
cell.bannerView.accessibilityTraits.remove(.button)
// Make sure refresh button is correct size.
cell.layoutIfNeeded()
let layout = UICollectionViewCompositionalLayout.list(using: configuration)
return layout
}
return dataSource
}
func makeAddedSourcesDataSource() -> RSTFetchedResultsCollectionViewDataSource<Source>
func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<Source, UIImage>
{
let fetchRequest = Source.fetchRequest() as NSFetchRequest<Source>
fetchRequest.returnsObjectsAsFaults = false
@@ -195,13 +215,129 @@ private extension SourcesViewController
NSSortDescriptor(keyPath: \Source.identifier, ascending: true)]
let dataSource = RSTFetchedResultsCollectionViewDataSource<Source>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
return dataSource
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: nil, cacheName: nil)
fetchedResultsController.delegate = self
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<Source, UIImage>(fetchedResultsController: fetchedResultsController)
dataSource.proxy = self
dataSource.cellConfigurationHandler = { [weak self] (cell, source, indexPath) in
guard let self else { return }
let cell = cell as! AppBannerCollectionViewCell
cell.layoutMargins.top = 5
cell.layoutMargins.bottom = 5
cell.layoutMargins.left = self.view.layoutMargins.left
cell.layoutMargins.right = self.view.layoutMargins.right
cell.bannerView.configure(for: source)
cell.bannerView.iconImageView.image = nil
cell.bannerView.iconImageView.isIndicatingActivity = true
let numberOfApps: Int
if let patreonAccount = DatabaseManager.shared.patreonAccount(), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated
{
numberOfApps = source.apps.count
}
else
{
numberOfApps = source.apps.filter { !$0.isBeta }.count
}
func makeTrustedSourcesDataSource() -> RSTArrayCollectionViewDataSource<Source>
if let error = source.error
{
let dataSource = RSTArrayCollectionViewDataSource<Source>(items: [])
let image = UIImage(systemName: "exclamationmark")?.withTintColor(.white, renderingMode: .alwaysOriginal)
cell.bannerView.button.setImage(image, for: .normal)
cell.bannerView.button.setTitle(nil, for: .normal)
cell.bannerView.button.tintColor = .systemYellow.withAlphaComponent(0.75)
let action = UIAction(identifier: .showError) { _ in
self.present(error)
}
cell.bannerView.button.addAction(action, for: .primaryActionTriggered)
cell.bannerView.button.removeAction(identifiedBy: .showDetails, for: .primaryActionTriggered)
}
else
{
cell.bannerView.button.setImage(nil, for: .normal)
cell.bannerView.button.setTitle(numberOfApps.description, for: .normal)
cell.bannerView.button.tintColor = .white.withAlphaComponent(0.2)
let action = UIAction(identifier: .showDetails) { _ in
self.showSourceDetails(for: source)
}
cell.bannerView.button.addAction(action, for: .primaryActionTriggered)
cell.bannerView.button.removeAction(identifiedBy: .showError, for: .primaryActionTriggered)
}
let dateText: String
if let lastUpdatedDate = source.lastUpdatedDate
{
dateText = Date().relativeDateString(since: lastUpdatedDate, dateFormatter: self.dateFormatter)
}
else
{
dateText = NSLocalizedString("Never", comment: "")
}
let text = String(format: NSLocalizedString("Last Updated: %@", comment: ""), dateText)
cell.bannerView.subtitleLabel.text = text
cell.bannerView.subtitleLabel.numberOfLines = 1
let numberOfAppsText: String
if #available(iOS 15, *)
{
let attributedOutput = AttributedString(localized: "^[\(numberOfApps) app](inflect: true)")
numberOfAppsText = String(attributedOutput.characters)
}
else
{
numberOfAppsText = ""
}
let accessibilityLabel = source.name + "\n" + text + ".\n" + numberOfAppsText
cell.bannerView.accessibilityLabel = accessibilityLabel
if source.identifier != Source.altStoreIdentifier
{
cell.accessories = [.delete(displayed: .whenEditing)]
}
else
{
cell.accessories = []
}
cell.bannerView.accessibilityTraits.remove(.button)
// Make sure refresh button is correct size.
cell.layoutIfNeeded()
}
dataSource.prefetchHandler = { (source, indexPath, completionHandler) in
guard let imageURL = source.effectiveIconURL else { return nil }
return RSTAsyncBlockOperation() { (operation) in
ImagePipeline.shared.loadImage(with: imageURL, progress: nil) { result in
guard !operation.isCancelled else { return operation.finish() }
switch result
{
case .success(let response): completionHandler(response.image, nil)
case .failure(let error): completionHandler(nil, error)
}
}
}
}
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
let cell = cell as! AppBannerCollectionViewCell
cell.bannerView.iconImageView.isIndicatingActivity = false
cell.bannerView.iconImageView.image = image
if let error = error
{
print("Error loading image:", error)
}
}
return dataSource
}
@@ -217,7 +353,7 @@ private extension SourcesViewController
private extension SourcesViewController
{
@IBAction func addSource()
func handleAddSourceDeepLink()
{
let alertController = UIAlertController(title: NSLocalizedString("Add Source", comment: ""), message: nil, preferredStyle: .alert)
alertController.addTextField { (textField) in
@@ -239,19 +375,12 @@ private extension SourcesViewController
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = false
}
})
guard let url = self.deepLinkSourceURL, self.view.window != nil else { return }
self.present(alertController, animated: true, completion: nil)
}
func addSource(url: URL, completionHandler: ((Result<Void, Error>) -> Void)? = nil)
{
guard self.view.window != nil else { return }
if url == self.deepLinkSourceURL
{
// Only handle deep link once.
self.deepLinkSourceURL = nil
}
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = true
func finish(_ result: Result<Void, Error>)
{
@@ -270,37 +399,17 @@ private extension SourcesViewController
self.present(error.withLocalizedTitle(NSLocalizedString("Unable to Add Source", comment: "")))
}
self.collectionView.reloadSections([Section.trusted.rawValue])
completionHandler?(result)
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = false
}
}
var dependencies: [Foundation.Operation] = []
if let fetchTrustedSourcesOperation = self.fetchTrustedSourcesOperation
{
// Must fetch trusted sources first to determine whether this is a trusted source.
// We assume fetchTrustedSources() has already been called before this method.
dependencies = [fetchTrustedSourcesOperation]
}
AppManager.shared.fetchSource(sourceURL: url, dependencies: dependencies) { (result) in
AppManager.shared.fetchSource(sourceURL: url) { (result) in
do
{
// Use @Managed before calling perform() to keep
// strong reference to source.managedObjectContext.
@Managed var source = try result.get()
let backgroundContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()
backgroundContext.perform {
do
{
let predicate = NSPredicate(format: "%K == %@", #keyPath(Source.identifier), $source.identifier)
if let existingSource = Source.first(satisfying: predicate, in: backgroundContext)
{
throw SourceError.duplicate(source, existingSource: existingSource)
}
DispatchQueue.main.async {
self.showSourceDetails(for: source)
}
@@ -313,12 +422,6 @@ private extension SourcesViewController
}
}
}
catch
{
finish(.failure(error))
}
}
}
func present(_ error: Error)
{
@@ -340,42 +443,22 @@ private extension SourcesViewController
self.present(alertController, animated: true, completion: nil)
}
func fetchTrustedSources()
func remove(_ source: Source, completionHandler: ((Bool) -> Void)? = nil)
{
// Closure instead of local function so we can capture `self` weakly.
let finish: (Result<[Source], Error>) -> Void = { [weak self] result in
self?.fetchTrustedSourcesResult = result.map { _ in () }
DispatchQueue.main.async {
Task<Void, Never> {
do
{
let sources = try result.get()
print("Fetched trusted sources:", sources.map { $0.identifier })
try await AppManager.shared.remove(source, presentingViewController: self)
let sectionUpdate = RSTCellContentChange(type: .update, sectionIndex: 0)
self?.trustedSourcesDataSource.setItems(sources, with: [sectionUpdate])
completionHandler?(true)
}
catch is CancellationError
{
completionHandler?(false)
}
catch
{
print("Error fetching trusted sources:", error)
let sectionUpdate = RSTCellContentChange(type: .update, sectionIndex: 0)
self?.trustedSourcesDataSource.setItems([], with: [sectionUpdate])
}
}
}
self.fetchTrustedSourcesOperation = AppManager.shared.updateKnownSources { [weak self] result in
switch result
{
case .failure(let error): finish(.failure(error))
case .success((let trustedSources, _)):
// Don't show sources without a sourceURL.
let featuredSourceURLs = trustedSources.compactMap { $0.sourceURL }
// This context is never saved, but keeps the managed sources alive.
let context = DatabaseManager.shared.persistentContainer.newBackgroundSavingViewContext()
self?._fetchTrustedSourcesContext = context
completionHandler?(false)
let dispatchGroup = DispatchGroup()
@@ -410,60 +493,36 @@ private extension SourcesViewController
let sources = featuredSourceURLs.compactMap { sourcesByURL[$0] }
finish(.success(sources))
}
}
}
}
@IBAction func addTrustedSource(_ sender: PillButton)
{
let point = self.collectionView.convert(sender.center, from: sender.superview)
guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return }
let completedProgress = Progress(totalUnitCount: 1)
completedProgress.completedUnitCount = 1
sender.progress = completedProgress
let source = self.dataSource.item(at: indexPath)
self.addSource(url: source.sourceURL) { _ in
//FIXME: Handle cell reuse.
sender.progress = nil
}
}
func remove(_ source: Source)
{
let alertController = UIAlertController(title: String(format: NSLocalizedString("Are you sure you want to remove the source “%@”?", comment: ""), source.name),
message: NSLocalizedString("Any apps you've installed from this source will remain, but they'll no longer receive any app updates.", comment: ""), preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style, handler: nil))
alertController.addAction(UIAlertAction(title: NSLocalizedString("Remove Source", comment: ""), style: .destructive) { _ in
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
let source = context.object(with: source.objectID) as! Source
context.delete(source)
do
{
try context.save()
DispatchQueue.main.async {
self.collectionView.reloadSections([Section.trusted.rawValue])
}
}
catch
{
DispatchQueue.main.async {
self.present(error)
}
}
}
})
self.present(alertController, animated: true, completion: nil)
}
func showSourceDetails(for source: Source)
{
self.performSegue(withIdentifier: "showSourceDetails", sender: source)
}
func update()
{
if self.dataSource.itemCount < 2
{
// Show placeholder view
self.placeholderView.isHidden = false
self.collectionView.alwaysBounceVertical = false
self.setEditing(false, animated: true)
self.editButtonItem.isEnabled = false
}
else
{
self.placeholderView.isHidden = true
self.collectionView.alwaysBounceVertical = true
self.editButtonItem.isEnabled = true
}
}
}
extension SourcesViewController
@@ -475,55 +534,17 @@ extension SourcesViewController
let source = self.dataSource.item(at: indexPath)
self.showSourceDetails(for: source)
}
}
extension SourcesViewController: UICollectionViewDelegateFlowLayout
{
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
{
return CGSize(width: collectionView.bounds.width, height: 80)
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize
{
let indexPath = IndexPath(row: 0, section: section)
let headerView = self.collectionView(collectionView, viewForSupplementaryElementOfKind: UICollectionView.elementKindSectionHeader, at: indexPath)
// Use this view to calculate the optimal size based on the collection view's width
let size = headerView.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingExpandedSize.height),
withHorizontalFittingPriority: .required, // Width is fixed
verticalFittingPriority: .fittingSizeLevel) // Height can be as large as needed
return size
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize
{
guard Section(rawValue: section) == .trusted else { return .zero }
let indexPath = IndexPath(row: 0, section: section)
let headerView = self.collectionView(collectionView, viewForSupplementaryElementOfKind: UICollectionView.elementKindSectionFooter, at: indexPath)
// Use this view to calculate the optimal size based on the collection view's width
let size = headerView.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingExpandedSize.height),
withHorizontalFittingPriority: .required, // Width is fixed
verticalFittingPriority: .fittingSizeLevel) // Height can be as large as needed
return size
}
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
{
let reuseIdentifier = (kind == UICollectionView.elementKindSectionHeader) ? "Header" : "Footer"
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! UICollectionViewListCell
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: reuseIdentifier, for: indexPath) as! TextCollectionReusableView
headerView.layoutMargins.left = self.view.layoutMargins.left
headerView.layoutMargins.right = self.view.layoutMargins.right
var configuation = UIListContentConfiguration.cell()
configuation.text = NSLocalizedString("Sources control what apps are available to download through AltStore.", comment: "")
configuation.textProperties.color = .secondaryLabel
configuation.textProperties.alignment = .natural
/* Changing NSLayoutConstraint priorities from required to optional (and vice versa) isnt supported, and crashes on iOS 12. */
// let almostRequiredPriority = UILayoutPriority(UILayoutPriority.required.rawValue - 1) // Can't be required or else we can't satisfy constraints when hidden (size = 0).
// headerView.leadingLayoutConstraint?.priority = almostRequiredPriority
// headerView.trailingLayoutConstraint?.priority = almostRequiredPriority
// headerView.topLayoutConstraint?.priority = almostRequiredPriority
// headerView.bottomLayoutConstraint?.priority = almostRequiredPriority
headerView.contentConfiguration = configuation
switch kind
{
@@ -607,78 +628,79 @@ extension SourcesViewController: UICollectionViewDelegateFlowLayout
}
}
extension SourcesViewController
extension SourcesViewController: NSFetchedResultsControllerDelegate
{
override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration?
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>)
{
let source = self.dataSource.item(at: indexPath)
return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: nil) { (suggestedActions) -> UIMenu? in
let viewErrorAction = UIAction(title: NSLocalizedString("View Error", comment: ""), image: UIImage(systemName: "exclamationmark.circle")) { (action) in
guard let error = source.error else { return }
self.present(error)
self.dataSource.controllerWillChangeContent(controller)
}
let deleteAction = UIAction(title: NSLocalizedString("Remove", comment: ""), image: UIImage(systemName: "trash"), attributes: [.destructive]) { (action) in
self.remove(source)
}
let addAction = UIAction(title: String(format: NSLocalizedString("Add “%@”", comment: ""), source.name), image: UIImage(systemName: "plus")) { (action) in
self.addSource(url: source.sourceURL)
}
var actions: [UIAction] = []
if source.error != nil
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>)
{
actions.append(viewErrorAction)
self.update()
self.dataSource.controllerDidChangeContent(controller)
}
switch Section.allCases[indexPath.section]
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?)
{
case .added:
if source.identifier != Source.altStoreIdentifier
self.dataSource.controller(controller, didChange: anObject, at: indexPath, for: type, newIndexPath: newIndexPath)
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType)
{
actions.append(deleteAction)
}
case .trusted:
if let cell = collectionView.cellForItem(at: indexPath) as? AppBannerCollectionViewCell, !cell.bannerView.button.isHidden
{
actions.append(addAction)
}
}
guard !actions.isEmpty else { return nil }
let menu = UIMenu(title: "", children: actions)
return menu
}
}
override func collectionView(_ collectionView: UICollectionView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview?
{
guard let indexPath = configuration.identifier as? NSIndexPath else { return nil }
guard let cell = collectionView.cellForItem(at: indexPath as IndexPath) as? AppBannerCollectionViewCell else { return nil }
let parameters = UIPreviewParameters()
parameters.backgroundColor = .clear
parameters.visiblePath = UIBezierPath(roundedRect: cell.bannerView.bounds, cornerRadius: cell.bannerView.layer.cornerRadius)
let preview = UITargetedPreview(view: cell.bannerView, parameters: parameters)
return preview
}
override func collectionView(_ collectionView: UICollectionView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview?
{
return self.collectionView(collectionView, previewForHighlightingContextMenuWithConfiguration: configuration)
self.dataSource.controller(controller, didChange: sectionInfo, atSectionIndex: UInt(sectionIndex), for: type)
}
}
extension SourcesViewController: UITextViewDelegate
{
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool
{
return true
@available(iOS 17, *)
#Preview(traits: .portrait) {
DatabaseManager.shared.startForPreview()
let storyboard = UIStoryboard(name: "Sources", bundle: nil)
let sourcesViewController = storyboard.instantiateInitialViewController()!
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
context.performAndWait {
_ = Source.make(name: "OatmealDome's AltStore Source",
identifier: "me.oatmealdome.altstore",
sourceURL: URL(string: "https://altstore.oatmealdome.me")!,
context: context)
_ = Source.make(name: "UTM Repository",
identifier: "com.utmapp.repos.UTM",
sourceURL: URL(string: "https://alt.getutm.app")!,
context: context)
_ = Source.make(name: "Flyinghead",
identifier: "com.flyinghead.source",
sourceURL: URL(string: "https://flyinghead.github.io/flycast-builds/altstore.json")!,
context: context)
_ = Source.make(name: "Provenance",
identifier: "org.provenance-emu.AltStore",
sourceURL: URL(string: "https://provenance-emu.com/apps.json")!,
context: context)
_ = Source.make(name: "PojavLauncher Repository",
identifier: "dev.crystall1ne.repos.PojavLauncher",
sourceURL: URL(string: "http://alt.crystall1ne.dev")!,
context: context)
try! context.save()
}
AppManager.shared.fetchSources { result in
do
{
let (sources, context) = try result.get()
try context.save()
}
catch
{
print("Preview failed to fetch sources:", error)
}
}
return sourcesViewController
}

View File

@@ -14,6 +14,7 @@ extension TabBarController
private enum Tab: Int, CaseIterable
{
case news
case sources
case browse
case myApps
case settings
@@ -26,6 +27,8 @@ final class TabBarController: UITabBarController
private var _viewDidAppear = false
private var sourcesViewController: SourcesViewController!
required init?(coder aDecoder: NSCoder)
{
super.init(coder: aDecoder)
@@ -36,6 +39,14 @@ final class TabBarController: UITabBarController
NotificationCenter.default.addObserver(self, selector: #selector(TabBarController.openErrorLog(_:)), name: ToastView.openErrorLogNotification, object: nil)
}
override func viewDidLoad()
{
super.viewDidLoad()
let sourcesNavigationController = self.viewControllers![Tab.sources.rawValue] as! UINavigationController
self.sourcesViewController = sourcesNavigationController.viewControllers.first as? SourcesViewController
}
override func viewDidAppear(_ animated: Bool)
{
super.viewDidAppear(animated)
@@ -63,15 +74,6 @@ final class TabBarController: UITabBarController
switch identifier
{
case "presentSources":
guard let notification = sender as? Notification,
let sourceURL = notification.userInfo?[AppDelegate.addSourceDeepLinkURLKey] as? URL
else { return }
let navigationController = segue.destination as! UINavigationController
let sourcesViewController = navigationController.viewControllers.first as! SourcesViewController
sourcesViewController.deepLinkSourceURL = sourceURL
case "finishJailbreak":
guard let installedApp = sender as? InstalledApp else { return }
@@ -103,31 +105,20 @@ extension TabBarController
@objc func presentSources(_ sender: Any)
{
if let presentedViewController = self.presentedViewController
{
if let navigationController = presentedViewController as? UINavigationController,
let sourcesViewController = navigationController.viewControllers.first as? SourcesViewController
{
if let notification = (sender as? Notification),
let sourceURL = notification.userInfo?[AppDelegate.addSourceDeepLinkURLKey] as? URL
{
sourcesViewController.deepLinkSourceURL = sourceURL
}
else
{
// Don't dismiss SourcesViewController if it's already presented.
}
}
else
{
presentedViewController.dismiss(animated: true) {
self.presentSources(sender)
}
}
return
}
self.performSegue(withIdentifier: "presentSources", sender: sender)
if let notification = (sender as? Notification), let sourceURL = notification.userInfo?[AppDelegate.addSourceDeepLinkURLKey] as? URL
{
self.sourcesViewController?.deepLinkSourceURL = sourceURL
}
self.selectedIndex = Tab.sources.rawValue
}
}

View File

@@ -386,6 +386,13 @@ public extension Source
}
return isRecommended
}
var lastUpdatedDate: Date? {
let allDates = self.apps.compactMap { $0.latestAvailableVersion?.date } + self.newsItems.map { $0.date }
let lastUpdatedDate = allDates.sorted().last
return lastUpdatedDate
}
}
internal extension Source
@@ -433,4 +440,14 @@ public extension Source
let source = Source.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Source.identifier), Source.altStoreIdentifier), in: context)
return source
}
class func make(name: String, identifier: String, sourceURL: URL, context: NSManagedObjectContext) -> Source
{
let source = Source(context: context)
source.name = name
source.identifier = identifier
source.sourceURL = sourceURL
return source
}
}