2023-04-04 15:41:44 -05:00
//
// S o u r c e s D e t a i l C o n t e n t V i e w C o n t r o l l e r . s w i f t
// A l t S t o r e
//
// C r e a t e d b y R i l e y T e s t u t o n 3 / 8 / 2 3 .
// C o p y r i g h t © 2 0 2 3 R i l e y T e s t u t . A l l r i g h t s r e s e r v e d .
//
import UIKit
2023-04-04 16:17:38 -05:00
import SafariServices
2023-04-04 15:41:44 -05:00
import AltStoreCore
import Roxas
import Nuke
private let sectionInset = 20.0
extension SourceDetailContentViewController
{
private enum Section : Int
{
case news
case featuredApps
case about
}
private enum ElementKind : String
{
case title
case button
}
}
class SourceDetailContentViewController : UICollectionViewController
{
let source : Source
private lazy var dataSource = self . makeDataSource ( )
private lazy var newsDataSource = self . makeNewsDataSource ( )
private lazy var appsDataSource = self . makeAppsDataSource ( )
private lazy var aboutDataSource = self . makeAboutDataSource ( )
override var collectionViewLayout : UICollectionViewCompositionalLayout {
return self . collectionView . collectionViewLayout as ! UICollectionViewCompositionalLayout
}
init ? ( source : Source , coder : NSCoder )
{
self . source = source
super . init ( coder : coder )
}
required init ? ( coder : NSCoder ) {
fatalError ( " init(coder:) has not been implemented " )
}
override func viewDidLoad ( )
{
super . viewDidLoad ( )
let collectionViewLayout = self . makeLayout ( source : self . source )
self . collectionView . collectionViewLayout = collectionViewLayout
self . collectionView . register ( NewsCollectionViewCell . nib , forCellWithReuseIdentifier : " NewsCell " )
self . collectionView . register ( TitleCollectionReusableView . self , forSupplementaryViewOfKind : ElementKind . title . rawValue , withReuseIdentifier : ElementKind . title . rawValue )
self . collectionView . register ( ButtonCollectionReusableView . self , forSupplementaryViewOfKind : ElementKind . button . rawValue , withReuseIdentifier : ElementKind . button . rawValue )
self . dataSource . proxy = self
self . collectionView . dataSource = self . dataSource
self . collectionView . prefetchDataSource = self . dataSource
}
override func viewSafeAreaInsetsDidChange ( )
{
super . viewSafeAreaInsetsDidChange ( )
// A d d s e c t i o n I n s e t t o s a f e A r e a I n s e t s . b o t t o m .
self . collectionView . contentInset = UIEdgeInsets ( top : sectionInset , left : 0 , bottom : self . view . safeAreaInsets . bottom + sectionInset , right : 0 )
}
}
private extension SourceDetailContentViewController
{
func makeLayout ( source : Source ) -> UICollectionViewCompositionalLayout
{
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration ( )
layoutConfig . interSectionSpacing = 10
let layout = UICollectionViewCompositionalLayout ( sectionProvider : { ( sectionIndex , layoutEnvironment ) -> NSCollectionLayoutSection ? in
guard let section = Section ( rawValue : sectionIndex ) else { return nil }
switch section
{
case . news :
guard ! source . newsItems . isEmpty else { return nil }
2023-10-16 19:12:42 -05:00
// U n d e r e s t i m a t e h e i g h t t o p r e v e n t j u m p i n g s i z e a b r u p t l y .
let heightDimension : NSCollectionLayoutDimension = if #available ( iOS 17 , * ) { . uniformAcrossSiblings ( estimate : 50 ) } else { . estimated ( 50 ) }
let itemSize = NSCollectionLayoutSize ( widthDimension : . fractionalWidth ( 1.0 ) , heightDimension : heightDimension )
2023-04-04 15:41:44 -05:00
let item = NSCollectionLayoutItem ( layoutSize : itemSize )
let groupWidth = layoutEnvironment . container . contentSize . width - sectionInset * 2
2023-10-16 19:12:42 -05:00
let groupSize = NSCollectionLayoutSize ( widthDimension : . absolute ( groupWidth ) , heightDimension : heightDimension )
2023-04-04 15:41:44 -05:00
let group = NSCollectionLayoutGroup . horizontal ( layoutSize : groupSize , subitems : [ item ] )
let buttonSize = NSCollectionLayoutSize ( widthDimension : . estimated ( 60 ) , heightDimension : . estimated ( 20 ) )
let sectionFooter = NSCollectionLayoutBoundarySupplementaryItem ( layoutSize : buttonSize , elementKind : ElementKind . button . rawValue , alignment : . bottomTrailing )
let layoutSection = NSCollectionLayoutSection ( group : group )
layoutSection . interGroupSpacing = 10
layoutSection . contentInsets = NSDirectionalEdgeInsets ( top : 0 , leading : sectionInset , bottom : 4 , trailing : sectionInset )
layoutSection . orthogonalScrollingBehavior = . groupPagingCentered
layoutSection . boundarySupplementaryItems = [ sectionFooter ]
return layoutSection
case . featuredApps :
// A l w a y s s h o w F e a t u r e d A p p s s e c t i o n , e v e n i f t h e r e a r e n o a p p s .
// g u a r d ! s o u r c e . e f f e c t i v e F e a t u r e d A p p s . i s E m p t y e l s e { r e t u r n n i l }
let itemSize = NSCollectionLayoutSize ( widthDimension : . fractionalWidth ( 1.0 ) , heightDimension : . absolute ( 88 ) )
let item = NSCollectionLayoutItem ( layoutSize : itemSize )
let group = NSCollectionLayoutGroup . vertical ( layoutSize : itemSize , subitems : [ item ] )
let titleSize = NSCollectionLayoutSize ( widthDimension : . estimated ( 75 ) , heightDimension : . estimated ( 40 ) )
let titleHeader = NSCollectionLayoutBoundarySupplementaryItem ( layoutSize : titleSize , elementKind : ElementKind . title . rawValue , alignment : . topLeading )
let buttonSize = NSCollectionLayoutSize ( widthDimension : . estimated ( 60 ) , heightDimension : . estimated ( 20 ) )
let buttonHeader = NSCollectionLayoutBoundarySupplementaryItem ( layoutSize : buttonSize , elementKind : ElementKind . button . rawValue , alignment : . bottomTrailing )
let layoutSection = NSCollectionLayoutSection ( group : group )
layoutSection . interGroupSpacing = 15
layoutSection . contentInsets = NSDirectionalEdgeInsets ( top : 15 /* i n d e p e n d e n t o f s e c t i o n I n s e t */ , leading : sectionInset , bottom : 4 , trailing : sectionInset )
layoutSection . orthogonalScrollingBehavior = . none
layoutSection . boundarySupplementaryItems = [ titleHeader , buttonHeader ]
return layoutSection
case . about :
guard source . localizedDescription != nil else { return nil }
let itemSize = NSCollectionLayoutSize ( widthDimension : . fractionalWidth ( 1.0 ) , heightDimension : . estimated ( 200 ) )
let item = NSCollectionLayoutItem ( layoutSize : itemSize )
let group = NSCollectionLayoutGroup . vertical ( layoutSize : itemSize , subitems : [ item ] )
let titleSize = NSCollectionLayoutSize ( widthDimension : . fractionalWidth ( 1.0 ) , heightDimension : . estimated ( 40 ) )
let titleHeader = NSCollectionLayoutBoundarySupplementaryItem ( layoutSize : titleSize , elementKind : ElementKind . title . rawValue , alignment : . topLeading )
let layoutSection = NSCollectionLayoutSection ( group : group )
layoutSection . contentInsets = NSDirectionalEdgeInsets ( top : 15 /* i n d e p e n d e n t o f s e c t i o n I n s e t */ , leading : sectionInset , bottom : 0 , trailing : sectionInset )
layoutSection . orthogonalScrollingBehavior = . none
layoutSection . boundarySupplementaryItems = [ titleHeader ]
return layoutSection
}
} , configuration : layoutConfig )
return layout
}
func makeDataSource ( ) -> RSTCompositeCollectionViewPrefetchingDataSource < NSManagedObject , UIImage >
{
let newsDataSource = self . newsDataSource as ! RSTFetchedResultsCollectionViewDataSource < NSManagedObject >
let appsDataSource = self . appsDataSource as ! RSTArrayCollectionViewPrefetchingDataSource < NSManagedObject , UIImage >
let dataSource = RSTCompositeCollectionViewPrefetchingDataSource < NSManagedObject , UIImage > ( dataSources : [ newsDataSource , appsDataSource , self . aboutDataSource ] )
return dataSource
}
func makeNewsDataSource ( ) -> RSTFetchedResultsCollectionViewDataSource < NewsItem >
{
let fetchRequest = NewsItem . sortedFetchRequest ( for : self . source )
let context = self . source . managedObjectContext ? ? DatabaseManager . shared . viewContext
let dataSource = RSTFetchedResultsCollectionViewDataSource ( fetchRequest : fetchRequest , managedObjectContext : context )
dataSource . liveFetchLimit = 5
dataSource . cellIdentifierHandler = { _ in " NewsCell " }
dataSource . cellConfigurationHandler = { ( cell , newsItem , indexPath ) in
let cell = cell as ! NewsCollectionViewCell
// F o r s o m e r e a s o n , s e t t i n g c e l l . l a y o u t M a r g i n s = . z e r o d o e s n o t u p d a t e c e l l . c o n t e n t V i e w . l a y o u t M a r g i n s .
cell . layoutMargins = . zero
cell . contentView . layoutMargins = . zero
cell . titleLabel . text = newsItem . title
cell . captionLabel . text = newsItem . caption
cell . contentBackgroundView . backgroundColor = newsItem . tintColor
cell . imageView . image = nil
cell . imageView . isHidden = true
cell . isAccessibilityElement = true
cell . accessibilityLabel = ( cell . titleLabel . text ? ? " " ) + " . " + ( cell . captionLabel . text ? ? " " )
if newsItem . storeApp != nil || newsItem . externalURL != nil
{
cell . accessibilityTraits . insert ( . button )
}
else
{
cell . accessibilityTraits . remove ( . button )
}
}
return dataSource
}
func makeAppsDataSource ( ) -> RSTArrayCollectionViewPrefetchingDataSource < StoreApp , UIImage >
{
let featuredApps = self . source . effectiveFeaturedApps
let limitedFeaturedApps = Array ( featuredApps . prefix ( 5 ) )
let dataSource = RSTArrayCollectionViewPrefetchingDataSource < StoreApp , UIImage > ( items : limitedFeaturedApps )
dataSource . cellIdentifierHandler = { _ in " AppCell " }
dataSource . predicate = NSPredicate ( format : " %K == NO " , # keyPath ( StoreApp . isBeta ) ) // N e v e r s h o w b e t a a p p s ( a t l e a s t u n t i l w e s u p p o r t b e t a s f o r o t h e r s o u r c e s ) .
dataSource . cellConfigurationHandler = { [ weak self ] ( cell , storeApp , indexPath ) in
let cell = cell as ! AppBannerCollectionViewCell
cell . tintColor = storeApp . tintColor
// F o r s o m e r e a s o n , s e t t i n g c e l l . l a y o u t M a r g i n s = . z e r o d o e s n o t u p d a t e c e l l . c o n t e n t V i e w . l a y o u t M a r g i n s .
cell . layoutMargins = . zero
cell . contentView . layoutMargins = . zero
cell . bannerView . configure ( for : storeApp )
cell . bannerView . iconImageView . isIndicatingActivity = true
cell . bannerView . buttonLabel . isHidden = true
cell . bannerView . button . isIndicatingActivity = false
cell . bannerView . button . tintColor = storeApp . tintColor
let buttonTitle = NSLocalizedString ( " Free " , comment : " " )
cell . bannerView . button . setTitle ( buttonTitle . uppercased ( ) , for : . normal )
cell . bannerView . button . accessibilityLabel = String ( format : NSLocalizedString ( " Download %@ " , comment : " " ) , storeApp . name )
cell . bannerView . button . accessibilityValue = buttonTitle
2023-04-04 16:55:55 -05:00
cell . bannerView . button . addTarget ( self , action : #selector ( SourceDetailContentViewController . addSourceThenDownloadApp ( _ : ) ) , for : . primaryActionTriggered )
2023-04-04 15:41:44 -05:00
let progress = AppManager . shared . installationProgress ( for : storeApp )
cell . bannerView . button . progress = progress
if let versionDate = storeApp . latestSupportedVersion ? . date , versionDate > Date ( )
{
cell . bannerView . button . countdownDate = versionDate
}
else
{
cell . bannerView . button . countdownDate = nil
}
// M a k e s u r e r e f r e s h b u t t o n i s c o r r e c t s i z e .
cell . layoutIfNeeded ( )
if let progress = AppManager . shared . installationProgress ( for : storeApp ) , progress . fractionCompleted < 1.0
{
cell . bannerView . button . progress = progress
}
else
{
cell . bannerView . button . progress = nil
}
}
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 = { ( cell , image , indexPath , error ) in
let cell = cell as ! AppBannerCollectionViewCell
cell . bannerView . iconImageView . image = image
cell . bannerView . iconImageView . isIndicatingActivity = false
if let error
{
print ( " [ALTLog] Error loading source icon: " , error )
}
}
return dataSource
}
func makeAboutDataSource ( ) -> RSTDynamicCollectionViewDataSource < NSManagedObject >
{
let dataSource = RSTDynamicCollectionViewDataSource < NSManagedObject > ( )
dataSource . numberOfSectionsHandler = { 1 }
2023-05-24 19:13:40 -05:00
dataSource . numberOfItemsHandler = { [ source ] _ in source . localizedDescription = = nil ? 0 : 1 }
2023-04-04 15:41:44 -05:00
dataSource . cellIdentifierHandler = { _ in " AboutCell " }
2023-05-24 19:13:40 -05:00
dataSource . cellConfigurationHandler = { [ source ] ( cell , _ , indexPath ) in
2023-04-04 15:41:44 -05:00
let cell = cell as ! TextViewCollectionViewCell
cell . contentView . layoutMargins = . zero // F i x e s i n c o r r e c t m a r g i n s i f n o t i n i t i a l l y o n s c r e e n .
2023-05-24 19:13:40 -05:00
cell . textView . text = source . localizedDescription
2023-04-04 15:41:44 -05:00
cell . textView . isCollapsed = false
}
return dataSource
}
}
2023-04-04 16:17:38 -05:00
private extension SourceDetailContentViewController
{
@objc func viewAllNews ( )
{
self . performSegue ( withIdentifier : " showAllNews " , sender : nil )
}
@objc func viewAllApps ( )
{
self . performSegue ( withIdentifier : " showAllApps " , sender : nil )
}
@ IBSegueAction
func makeNewsViewController ( _ coder : NSCoder ) -> UIViewController ?
{
let newsViewController = NewsViewController ( source : self . source , coder : coder )
return newsViewController
}
@ IBSegueAction
func makeBrowseViewController ( _ coder : NSCoder ) -> UIViewController ?
{
let browseViewController = BrowseViewController ( source : self . source , coder : coder )
return browseViewController
}
}
2023-04-04 15:41:44 -05:00
extension SourceDetailContentViewController
{
override func collectionView ( _ collectionView : UICollectionView , viewForSupplementaryElementOfKind kind : String , at indexPath : IndexPath ) -> UICollectionReusableView
{
let supplementaryView = collectionView . dequeueReusableSupplementaryView ( ofKind : kind , withReuseIdentifier : kind , for : indexPath )
let section = Section ( rawValue : indexPath . section ) !
let kind = ElementKind ( rawValue : kind ) !
switch ( section , kind )
{
case ( . news , _ ) :
let buttonView = supplementaryView as ! ButtonCollectionReusableView
buttonView . button . setTitle ( NSLocalizedString ( " View All " , comment : " " ) , for : . normal )
2023-04-04 16:17:38 -05:00
buttonView . button . removeTarget ( self , action : nil , for : . primaryActionTriggered )
buttonView . button . addTarget ( self , action : #selector ( SourceDetailContentViewController . viewAllNews ) , for : . primaryActionTriggered )
2023-04-04 15:41:44 -05:00
case ( . featuredApps , . title ) :
let titleView = supplementaryView as ! TitleCollectionReusableView
titleView . label . text = NSLocalizedString ( " Featured Apps " , comment : " " )
case ( . featuredApps , . button ) :
let buttonView = supplementaryView as ! ButtonCollectionReusableView
buttonView . button . setTitle ( NSLocalizedString ( " View All Apps " , comment : " " ) , for : . normal )
2023-04-04 16:17:38 -05:00
buttonView . button . removeTarget ( self , action : nil , for : . primaryActionTriggered )
buttonView . button . addTarget ( self , action : #selector ( SourceDetailContentViewController . viewAllApps ) , for : . primaryActionTriggered )
2023-04-04 15:41:44 -05:00
case ( . about , _ ) :
let titleView = supplementaryView as ! TitleCollectionReusableView
titleView . label . text = NSLocalizedString ( " About " , comment : " " )
}
return supplementaryView
}
2023-04-04 16:17:38 -05:00
override func collectionView ( _ collectionView : UICollectionView , didSelectItemAt indexPath : IndexPath )
{
let section = Section ( rawValue : indexPath . section ) !
let item = self . dataSource . item ( at : indexPath )
switch ( section , item )
{
case ( . news , let newsItem as NewsItem ) :
if let externalURL = newsItem . externalURL
{
let safariViewController = SFSafariViewController ( url : externalURL )
safariViewController . preferredControlTintColor = newsItem . tintColor
self . present ( safariViewController , animated : true , completion : nil )
}
else if let storeApp = newsItem . storeApp
{
let appViewController = AppViewController . makeAppViewController ( app : storeApp )
self . navigationController ? . pushViewController ( appViewController , animated : true )
}
case ( . featuredApps , let storeApp as StoreApp ) :
let appViewController = AppViewController . makeAppViewController ( app : storeApp )
self . navigationController ? . pushViewController ( appViewController , animated : true )
default : break
}
}
2023-04-04 15:41:44 -05:00
}
2023-04-04 16:55:55 -05:00
private extension SourceDetailContentViewController
{
@objc func addSourceThenDownloadApp ( _ sender : UIButton )
{
let point = self . collectionView . convert ( sender . center , from : sender . superview )
guard let indexPath = self . collectionView . indexPathForItem ( at : point ) else { return }
sender . isIndicatingActivity = true
let storeApp = self . dataSource . item ( at : indexPath ) as ! StoreApp
Task < Void , Never > {
do
{
let isAdded = try await self . source . isAdded
if ! isAdded
{
let message = String ( format : NSLocalizedString ( " You must add this source before you can install apps from it. \n \n “%@” will begin downloading once it has been added. " , comment : " " ) , storeApp . name )
try await AppManager . shared . add ( self . source , message : message , presentingViewController : self )
}
do
{
try await self . downloadApp ( storeApp )
}
catch OperationError . cancelled { }
catch
{
let toastView = ToastView ( error : error )
toastView . opensErrorLog = true
toastView . show ( in : self )
}
}
catch is CancellationError { }
catch
{
await self . presentAlert ( title : NSLocalizedString ( " Unable to Add Source " , comment : " " ) , message : error . localizedDescription )
}
sender . isIndicatingActivity = false
self . collectionView . reloadSections ( [ Section . featuredApps . rawValue ] )
}
}
func downloadApp ( _ storeApp : StoreApp ) async throws
{
try await withCheckedThrowingContinuation { ( continuation : CheckedContinuation < Void , Error > ) in
AppManager . shared . install ( storeApp , presentingViewController : self ) { result in
continuation . resume ( with : result . map { _ in } )
}
guard let index = self . appsDataSource . items . firstIndex ( of : storeApp ) else {
self . collectionView . reloadSections ( [ Section . featuredApps . rawValue ] )
return
}
let indexPath = IndexPath ( item : index , section : Section . featuredApps . rawValue )
self . collectionView . reloadItems ( at : [ indexPath ] )
}
}
}
2023-04-04 15:41:44 -05:00
extension SourceDetailContentViewController : ScrollableContentViewController
{
var scrollView : UIScrollView { self . collectionView }
}