diff --git a/Zebra.xcodeproj/project.pbxproj b/Zebra.xcodeproj/project.pbxproj index da815f9cfc..a91abc2a1f 100644 --- a/Zebra.xcodeproj/project.pbxproj +++ b/Zebra.xcodeproj/project.pbxproj @@ -70,7 +70,9 @@ 4EA26EBA27CCEEBC0019A5AA /* BrowseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA26EB927CCEEBC0019A5AA /* BrowseViewController.swift */; }; 4EA26EC427CCF6500019A5AA /* DepictionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 4EA26EC327CCF6500019A5AA /* DepictionKit */; }; 4EA2F1DD27DA2A0D0080DC35 /* Installed.pack in Resources */ = {isa = PBXBuildFile; fileRef = 4EA2F1DC27DA2A0D0080DC35 /* Installed.pack */; }; + 4EA833972875544B00029B9C /* SectionDateHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA833962875544B00029B9C /* SectionDateHeaderView.swift */; }; 4EA8339928755AA800029B9C /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA8339828755AA800029B9C /* NumberFormatter.swift */; }; + 4EA8339B287570B000029B9C /* WebImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA8339A287570B000029B9C /* WebImageView.swift */; }; 4EA893DD27DDABD600E2EC7A /* sandboxed.json in Resources */ = {isa = PBXBuildFile; fileRef = 4EA893DC27DDABD600E2EC7A /* sandboxed.json */; }; 4EA893DF27DF3D2100E2EC7A /* Dictionary+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA893DE27DF3D2100E2EC7A /* Dictionary+Extensions.swift */; }; 4EAC00452863390700B80531 /* SourceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1428E7D277A2731005B0885 /* SourceTableViewCell.swift */; }; @@ -278,7 +280,9 @@ 4EA26EB927CCEEBC0019A5AA /* BrowseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewController.swift; sourceTree = ""; }; 4EA26EC227CCF5430019A5AA /* DepictionKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = DepictionKit; path = Vendor/DepictionKit; sourceTree = ""; }; 4EA2F1DC27DA2A0D0080DC35 /* Installed.pack */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Installed.pack; sourceTree = ""; }; + 4EA833962875544B00029B9C /* SectionDateHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionDateHeaderView.swift; sourceTree = ""; }; 4EA8339828755AA800029B9C /* NumberFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberFormatter.swift; sourceTree = ""; }; + 4EA8339A287570B000029B9C /* WebImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebImageView.swift; sourceTree = ""; }; 4EA893DC27DDABD600E2EC7A /* sandboxed.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = sandboxed.json; sourceTree = ""; }; 4EA893DE27DF3D2100E2EC7A /* Dictionary+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+Extensions.swift"; sourceTree = ""; }; 4EB23508283D2F5F00713CBB /* URLSession+Additions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSession+Additions.swift"; sourceTree = ""; }; @@ -900,7 +904,9 @@ 89B701D42641CAAA00F50DAD /* ZBErrorViewController.mm */, 4EBA840B27D1F9A100766DBE /* SectionHeaderView.swift */, 4E840B3827D61ED700039938 /* SectionHeaderButton.swift */, + 4EA833962875544B00029B9C /* SectionDateHeaderView.swift */, 4EBA840D27D21F4700766DBE /* InfoFooterView.swift */, + 4EA8339A287570B000029B9C /* WebImageView.swift */, 4EBA841A27D3119800766DBE /* IconImageView.swift */, 4E840B3A27D6484900039938 /* GradientView.swift */, 4E138C67284C690C0058D94D /* ProgressDonut.swift */, @@ -1525,7 +1531,9 @@ 4EB8C48728583EAD00FFC744 /* NavigationController.swift in Sources */, 4EC7F28527B28D770078F953 /* Command.swift in Sources */, 4EC7F29C27B3A19F0078F953 /* RootViewController.swift in Sources */, + 4EA8339B287570B000029B9C /* WebImageView.swift in Sources */, 4EA26EB727CCDAC80019A5AA /* BaseSceneDelegate.swift in Sources */, + 4EA833972875544B00029B9C /* SectionDateHeaderView.swift in Sources */, 4EB8C48528583C5500FFC744 /* NavigationBar.swift in Sources */, 4EA26EBA27CCEEBC0019A5AA /* BrowseViewController.swift in Sources */, 4E1F060828411A47006B3F0C /* PackageViewController.swift in Sources */, diff --git a/Zebra/Controllers/App/AppSceneDelegate.swift b/Zebra/Controllers/App/AppSceneDelegate.swift index 3752f58960..e6588b812d 100644 --- a/Zebra/Controllers/App/AppSceneDelegate.swift +++ b/Zebra/Controllers/App/AppSceneDelegate.swift @@ -81,7 +81,7 @@ extension AppSceneDelegate: NSToolbarDelegate { item.isNavigational = true item.action = #selector(RootViewController.goBack) item.image = UIImage(systemName: "chevron.backward") - item.label = .back + item.toolTip = .back default: break } diff --git a/Zebra/Controllers/Plains/SourceRefreshController.swift b/Zebra/Controllers/Plains/SourceRefreshController.swift index c3f904ef20..480b7f7869 100644 --- a/Zebra/Controllers/Plains/SourceRefreshController.swift +++ b/Zebra/Controllers/Plains/SourceRefreshController.swift @@ -122,6 +122,7 @@ class SourceRefreshController: NSObject { } static let refreshProgressDidChangeNotification = Notification.Name(rawValue: "SourceRefreshProgressDidChangeNotification") + static let refreshDidFinishNotification = Notification.Name(rawValue: "SourceRefreshDidFinishNotification") private static let legacySourceHosts = ["repo.dynastic.co", "apt.bingner.com"] @@ -700,6 +701,10 @@ class SourceRefreshController: NSObject { #endif endRefresh() + + DispatchQueue.main.async { + NotificationCenter.default.post(name: Self.refreshDidFinishNotification, object: nil) + } } private func cancel() { diff --git a/Zebra/Extensions/Array+Extensions.swift b/Zebra/Extensions/Array+Extensions.swift index 1b04f9aff7..7fc6af11f8 100644 --- a/Zebra/Extensions/Array+Extensions.swift +++ b/Zebra/Extensions/Array+Extensions.swift @@ -10,6 +10,10 @@ extension Array { func compact() -> [ElementOfResult] where Element == ElementOfResult? { compactMap { $0 } } + + func safeSubSequence(_ range: Range) -> SubSequence { + self[Swift.max(range.lowerBound, 0).. Bool { + range(of: regex, + options: options.union(.regularExpression)) != nil + } } diff --git a/Zebra/Extensions/UIFont+Additions.swift b/Zebra/Extensions/UIFont+Additions.swift index 69b6c5a728..8d1369b96d 100644 --- a/Zebra/Extensions/UIFont+Additions.swift +++ b/Zebra/Extensions/UIFont+Additions.swift @@ -13,9 +13,9 @@ extension UIFont { @objc static let monospace = UIFont.monospacedSystemFont(ofSize: 11, weight: .regular) @objc static let boldMonospace = UIFont.monospacedSystemFont(ofSize: 11, weight: .bold) - class func preferredFont(forTextStyle style: TextStyle, weight: Weight) -> UIFont { - let dynamicFont = preferredFont(forTextStyle: style) - let font = systemFont(ofSize: dynamicFont.pointSize, weight: weight) + class func preferredFont(forTextStyle style: TextStyle, scale: CGFloat = 1, minimumSize: CGFloat = 0, weight: Weight = .regular) -> UIFont { + let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: style) + let font = systemFont(ofSize: max(descriptor.pointSize * scale, minimumSize), weight: weight) return UIFontMetrics(forTextStyle: style) .scaledFont(for: font) } diff --git a/Zebra/Extensions/UIImage+Additions.swift b/Zebra/Extensions/UIImage+Additions.swift index 43687107e9..e0c965249b 100644 --- a/Zebra/Extensions/UIImage+Additions.swift +++ b/Zebra/Extensions/UIImage+Additions.swift @@ -56,7 +56,7 @@ extension UIImageView { /// > Note: Wait till the image view has been laid out before calling this. If `frame.size == .zero`, /// > the image load will be skipped. Call this from `layoutSubviews` to ensure relayout as needed. - func load(url: URL?, usingScale: Bool = false, fallbackImage: UIImage? = nil) { + @objc func load(url: URL?, usingScale: Bool = false, fallbackImage: UIImage? = nil) { let scale = (window?.screen ?? .main).scale var sources = Self.sources(url: url, scale: usingScale ? scale : 1) if sources.isEmpty || frame.size == .zero { diff --git a/Zebra/Extensions/UIListContentConfiguration+Additions.swift b/Zebra/Extensions/UIListContentConfiguration+Additions.swift index 6aa8d4479f..f60bd75010 100644 --- a/Zebra/Extensions/UIListContentConfiguration+Additions.swift +++ b/Zebra/Extensions/UIListContentConfiguration+Additions.swift @@ -14,7 +14,7 @@ extension UIListContentConfiguration { var config = UIListContentConfiguration.cell() config.textProperties.font = .preferredFont(forTextStyle: .headline) config.imageProperties.preferredSymbolConfiguration = .init(textStyle: .headline) - config.imageToTextPadding = 4 + config.imageToTextPadding = 10 return config } @@ -27,7 +27,7 @@ extension UIListContentConfiguration { config.secondaryTextProperties.numberOfLines = 1 config.textToSecondaryTextVerticalPadding = 2 config.imageProperties.preferredSymbolConfiguration = .init(textStyle: .headline) - config.imageToTextPadding = 4 + config.imageToTextPadding = 10 return config } diff --git a/Zebra/Model/PLPackage+Additions.swift b/Zebra/Model/PLPackage+Additions.swift index 8c44407ae0..85c1344bc8 100644 --- a/Zebra/Model/PLPackage+Additions.swift +++ b/Zebra/Model/PLPackage+Additions.swift @@ -11,10 +11,23 @@ import Plains extension Package { - // MARK: - Actions + // We do a bunch of cleaning up we shouldn‘t really need to do because Packix. + private static let osVersionRegex = "::ios(ios)?(\\d+(\\.\\d+)*).*$" var isCommercial: Bool { tags.contains("cydia::commercial") } + var isCompatible: Bool { + let minimumTag = tags.first(where: { $0.matches(regex: "^compatible_min\(Self.osVersionRegex)", options: .caseInsensitive) }) + let maximumTag = tags.first(where: { $0.matches(regex: "^compatible_max\(Self.osVersionRegex)", options: .caseInsensitive) }) + let minimumVersion = minimumTag?.replacingOccurrences(regex: "^compatible_min\(Self.osVersionRegex)", with: "$2", options: .caseInsensitive) ?? "0.0" + let maximumVersion = maximumTag?.replacingOccurrences(regex: "^compatible_max\(Self.osVersionRegex)", with: "$2", options: .caseInsensitive) ?? "99.99" + let systemVersion = UIDevice.current.systemVersion + return minimumVersion.compare(systemVersion, options: .numeric) != .orderedDescending && + maximumVersion.compare(systemVersion, options: .numeric) != .orderedAscending + } + + // MARK: - Actions + var possibleActions: ZBPackageActionType { var actions: ZBPackageActionType = [] if let _ = source { diff --git a/Zebra/Tabs/Home/Errors/ErrorsViewController.swift b/Zebra/Tabs/Home/Errors/ErrorsViewController.swift index ab02e15e0d..c72137153c 100644 --- a/Zebra/Tabs/Home/Errors/ErrorsViewController.swift +++ b/Zebra/Tabs/Home/Errors/ErrorsViewController.swift @@ -25,6 +25,8 @@ class ErrorsViewController: ListCollectionViewController { let section = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: layoutEnvironment) + section.interGroupSpacing = 8 + section.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0) section.contentInsetsReference = .none section.boundarySupplementaryItems = [.header] return section diff --git a/Zebra/Tabs/Home/HomeErrorCollectionViewCell.swift b/Zebra/Tabs/Home/HomeErrorCollectionViewCell.swift index 8a648dd807..81a2ca1601 100644 --- a/Zebra/Tabs/Home/HomeErrorCollectionViewCell.swift +++ b/Zebra/Tabs/Home/HomeErrorCollectionViewCell.swift @@ -22,7 +22,7 @@ class HomeErrorCollectionViewCell: UICollectionViewListCell { .customView(configuration: .init(customView: UIView(), placement: .trailing(), isHidden: true, - reservedLayoutWidth: .custom(15))) + reservedLayoutWidth: .custom(30))) ] } @@ -47,8 +47,8 @@ class HomeErrorCollectionViewCell: UICollectionViewListCell { var backgroundConfiguration = UIBackgroundConfiguration.clear() backgroundConfiguration.backgroundColor = state.isHighlighted ? .systemGray4 : .systemGray6 - backgroundConfiguration.cornerRadius = 20 - backgroundConfiguration.edgesAddingLayoutMarginsToBackgroundInsets = .all + backgroundConfiguration.cornerRadius = 15 + backgroundConfiguration.edgesAddingLayoutMarginsToBackgroundInsets = [.leading, .trailing] self.backgroundConfiguration = backgroundConfiguration.updated(for: state) } diff --git a/Zebra/Tabs/Home/HomeViewController.swift b/Zebra/Tabs/Home/HomeViewController.swift index f692285637..28b9fdf905 100644 --- a/Zebra/Tabs/Home/HomeViewController.swift +++ b/Zebra/Tabs/Home/HomeViewController.swift @@ -33,6 +33,8 @@ class HomeViewController: ListCollectionViewController { private var dataSource: UICollectionViewDiffableDataSource! + private var isVisible = false + override class func createLayout() -> CollectionViewCompositionalLayout { CollectionViewCompositionalLayout { index, environment in switch index { @@ -42,13 +44,15 @@ class HomeViewController: ListCollectionViewController { return section case 1: - let section = NSCollectionLayoutSection(group: .oneAcross(heightDimension: .estimated(72))) + let section = NSCollectionLayoutSection(group: .oneAcross(heightDimension: .estimated(52))) + section.interGroupSpacing = 15 + section.contentInsets = NSDirectionalEdgeInsets(top: 15, leading: 0, bottom: 15, trailing: 0) section.contentInsetsReference = .none return section default: let section = NSCollectionLayoutSection(group: .listGrid(environment: environment, - heightDimension: .estimated(52))) + heightDimension: .estimated(80))) section.contentInsetsReference = .none // section.boundarySupplementaryItems = [.header] return section @@ -95,13 +99,17 @@ class HomeViewController: ListCollectionViewController { fatalError() } }) + + NotificationCenter.default.addObserver(self, selector: #selector(refreshDidFinish), name: SourceRefreshController.refreshDidFinishNotification, object: nil) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) NotificationCenter.default.addObserver(self, selector: #selector(refreshProgressDidChange), name: SourceRefreshController.refreshProgressDidChangeNotification, object: nil) + update() refreshProgressDidChange() + updatePromotedPackages() } override func viewDidDisappear(_ animated: Bool) { @@ -113,13 +121,17 @@ class HomeViewController: ListCollectionViewController { @objc private func refreshProgressDidChange() { // Filter to only errors. Warnings are mostly annoying and not particularly useful. errorCount = UInt(SourceRefreshController.shared.refreshErrors.count) + ErrorManager.shared.errorCount(at: .error) + let percent = SourceRefreshController.shared.progress.fractionCompleted DispatchQueue.main.async { - self.update() + self.updateProgress(percent: percent) + } + } - let progress = SourceRefreshController.shared.progress - let percent = progress.fractionCompleted - self.navigationProgressBar?.setProgress(Float(percent), animated: true) + @objc private func refreshDidFinish() { + promotedPackages = nil + if isVisible { + updatePromotedPackages() } } @@ -141,13 +153,43 @@ class HomeViewController: ListCollectionViewController { snapshot.appendItems([.featured]) } snapshot.appendSections([.notice]) + dataSource.apply(snapshot, animatingDifferences: true, completion: nil) + } + + private func updateProgress(percent: Double) { + navigationProgressBar?.setProgress(Float(percent), animated: true) + + var snapshot = dataSource.snapshot() + snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .notice)) if Device.isDemo { snapshot.appendItems([.notice(reason: .sandboxed)]) } if errorCount > 0 { snapshot.appendItems([.notice(reason: .refreshErrors(count: errorCount))]) } - dataSource.apply(snapshot, animatingDifferences: true, completion: nil) + dataSource.apply(snapshot, animatingDifferences: false) + } + + private func updatePromotedPackages() { + if promotedPackages != nil { + return + } + + Task.detached { + let promotedPackages = await PromotedPackagesFetcher.getHomeCarouselItems() + + await MainActor.run { + self.promotedPackages = promotedPackages + + var snapshot = self.dataSource.snapshot() + if #available(iOS 15, *) { + snapshot.reconfigureItems([.featured]) + } else { + snapshot.reloadItems([.featured]) + } + self.dataSource.apply(snapshot, animatingDifferences: true) + } + } } } diff --git a/Zebra/UI/Carousel/CarouselItemCollectionViewCell.swift b/Zebra/UI/Carousel/CarouselItemCollectionViewCell.swift index e8c54d2615..8092ba70dd 100644 --- a/Zebra/UI/Carousel/CarouselItemCollectionViewCell.swift +++ b/Zebra/UI/Carousel/CarouselItemCollectionViewCell.swift @@ -13,10 +13,10 @@ class CarouselItemCollectionViewCell: UICollectionViewCell { static let size = CGSize(width: 314, height: 175) var item: CarouselItem? { - didSet { updateItem() } + didSet { setNeedsUpdateConfiguration() } } - private var imageView: UIImageView! + private var imageView: WebImageView! private var overlayView: GradientView! private var titleLabel: UILabel! private var detailLabel: UILabel! @@ -26,7 +26,7 @@ class CarouselItemCollectionViewCell: UICollectionViewCell { override init(frame: CGRect) { super.init(frame: frame) - imageView = UIImageView(frame: bounds) + imageView = WebImageView(frame: bounds) imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] imageView.backgroundColor = .systemBackground imageView.contentMode = .scaleAspectFill @@ -97,14 +97,9 @@ class CarouselItemCollectionViewCell: UICollectionViewCell { super.prepareForReuse() } - override func layoutSubviews() { - super.layoutSubviews() + override func updateConfiguration(using state: UICellConfigurationState) { + super.updateConfiguration(using: state) - imageView.load(url: item?.imageURL, - fallbackImage: UIImage(named: "banner-fallback")) - } - - private func updateItem() { let displayTitle = item?.displayTitle ?? true titleLabel.isHidden = !displayTitle detailLabel.isHidden = !displayTitle @@ -114,13 +109,10 @@ class CarouselItemCollectionViewCell: UICollectionViewCell { detailLabel.text = displayTitle ? item?.subtitle : nil accessibilityLabel = displayTitle ? nil : [item?.subtitle, item?.title].compact().joined(separator: ", ") - setNeedsLayout() - } + imageView.load(url: item?.imageURL, + fallbackImage: UIImage(named: "banner-fallback")) - override var isHighlighted: Bool { - didSet { - highlightView.alpha = isHighlighted ? 1 : 0 - } + highlightView.alpha = state.isHighlighted ? 1 : 0 } } diff --git a/Zebra/UI/Carousel/CarouselViewController.swift b/Zebra/UI/Carousel/CarouselViewController.swift index 058cddfa92..69febec6e3 100644 --- a/Zebra/UI/Carousel/CarouselViewController.swift +++ b/Zebra/UI/Carousel/CarouselViewController.swift @@ -10,7 +10,7 @@ import UIKit class CarouselViewController: ListCollectionViewController { - static let height: CGFloat = CarouselItemCollectionViewCell.size.height + (15 * 2) + static let height: CGFloat = CarouselItemCollectionViewCell.size.height + 10 + 15 var items = [CarouselItem]() { didSet { updateItems() } @@ -32,6 +32,7 @@ class CarouselViewController: ListCollectionViewController { internal var errorLabel: UILabel! private var dataSource: UICollectionViewDiffableDataSource! + private var preloadTasks = [IndexPath: KingfisherTask]() override class func createLayout() -> CollectionViewCompositionalLayout { CollectionViewCompositionalLayout { _, environment in @@ -41,7 +42,8 @@ class CarouselViewController: ListCollectionViewController { subitems: [NSCollectionLayoutItem(layoutSize: .full)]) let section = NSCollectionLayoutSection(group: group) section.interGroupSpacing = 20 - section.contentInsets = NSDirectionalEdgeInsets(top: 15, leading: 20, bottom: 15, trailing: 20) + section.contentInsetsReference = .layoutMargins + section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 0, bottom: 15, trailing: 0) switch environment.traitCollection.horizontalSizeClass { case .regular, .unspecified: @@ -160,4 +162,19 @@ extension CarouselViewController: UICollectionViewDelegateFlowLayout { // UIColl return self.collectionView(collectionView, previewForHighlightingContextMenuWithConfiguration: configuration) } + func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) { + for indexPath in indexPaths { + let item = items[indexPath.item] + preloadTasks[indexPath] = UIImageView.preload(url: item.imageURL, + screen: view.window?.screen ?? .main) + } + } + + func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) { + for indexPath in indexPaths { + preloadTasks[indexPath]?.cancel() + preloadTasks[indexPath] = nil + } + } + } diff --git a/Zebra/UI/Common/IconImageView.swift b/Zebra/UI/Common/IconImageView.swift index 9648b46893..4a5bc45f69 100644 --- a/Zebra/UI/Common/IconImageView.swift +++ b/Zebra/UI/Common/IconImageView.swift @@ -10,20 +10,19 @@ import UIKit class IconImageView: UIView { - var image: UIImage? { - get { imageView.image } - set { setImageURL(nil, usingScale: false, fallbackImage: newValue) } + var size: CGFloat = 29 { + didSet { invalidateIntrinsicContentSize() } } - private var currentImage: (url: URL?, usingScale: Bool, fallbackImage: UIImage?)? - private var backgroundView: UIView! - private var imageView: UIImageView! + private var imageView: WebImageView! private var borderView: UIView! - init() { + init(size: CGFloat = 29) { super.init(frame: .zero) + self.size = size + backgroundView = UIView(frame: bounds) backgroundView.autoresizingMask = [.flexibleWidth, .flexibleHeight] backgroundView.clipsToBounds = true @@ -31,7 +30,7 @@ class IconImageView: UIView { backgroundView.layer.cornerCurve = .continuous addSubview(backgroundView) - imageView = UIImageView(frame: bounds) + imageView = WebImageView(frame: bounds) imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] imageView.contentMode = .scaleAspectFit imageView.clipsToBounds = true @@ -54,6 +53,10 @@ class IconImageView: UIView { fatalError("init(coder:) has not been implemented") } + override var intrinsicContentSize: CGSize { + CGSize(width: size, height: size) + } + override func layoutSubviews() { super.layoutSubviews() @@ -61,10 +64,6 @@ class IconImageView: UIView { backgroundView.layer.cornerRadius = cornerRadius imageView.layer.cornerRadius = cornerRadius borderView.layer.cornerRadius = cornerRadius - - if let (url, usingScale, fallbackImage) = currentImage { - setImageURL(url, usingScale: usingScale, fallbackImage: fallbackImage) - } } override func didMoveToWindow() { @@ -78,28 +77,22 @@ class IconImageView: UIView { } func setImageURL(_ url: URL?, usingScale: Bool = true, fallbackImage: UIImage? = nil) { - currentImage = (url, usingScale, fallbackImage) imageView.load(url: url, usingScale: usingScale, fallbackImage: fallbackImage) } } extension UICellAccessory { - - static func iconImageView(url: URL?, usingScale: Bool = true, fallbackImage: UIImage? = nil, width: CGFloat = 29) -> UICellAccessory { - let view = IconImageView() + static func iconImageView(url: URL?, + usingScale: Bool = true, + fallbackImage: UIImage? = nil, + width: CGFloat = 29, + reservedLayoutWidth: LayoutDimension = .actual) -> UICellAccessory { + let view = IconImageView(size: width) view.setImageURL(url, usingScale: usingScale, fallbackImage: fallbackImage) - - NSLayoutConstraint.activate([ - view.widthAnchor.constraint(equalToConstant: width), - view.heightAnchor.constraint(equalToConstant: width) - ]) - return .customView(configuration: .init(customView: view, - placement: .leading(displayed: .always), - reservedLayoutWidth: .custom(width), - maintainsFixedSize: true)) + placement: .leading(), + reservedLayoutWidth: reservedLayoutWidth)) } - } diff --git a/Zebra/UI/Common/SectionDateHeaderView.swift b/Zebra/UI/Common/SectionDateHeaderView.swift new file mode 100644 index 0000000000..ecd3c5abda --- /dev/null +++ b/Zebra/UI/Common/SectionDateHeaderView.swift @@ -0,0 +1,117 @@ +// +// SectionDateHeaderView.swift +// Zebra +// +// Created by Adam Demasi on 6/7/2022. +// Copyright © 2022 Zebra Team. All rights reserved. +// + +import Foundation + +class SectionDateHeaderView: UICollectionReusableView, PinnableHeader { + + var title: String? { + get { label.text } + set { label.text = newValue } + } + + var dateTitle: String? { + get { dateLabel.text } + set { dateLabel.text = newValue } + } + + var isFirstItem = false { + didSet { updateToolbar() } + } + + var isPinned = false { + didSet { updateToolbar() } + } + + private var label: UILabel! + private var toolbar: UIToolbar! + private var dateLabel: UILabel! + private var dateToolbar: UIToolbar! + + override init(frame: CGRect) { + super.init(frame: frame) + + preservesSuperviewLayoutMargins = true + + toolbar = UIToolbar(frame: bounds) + toolbar.autoresizingMask = [.flexibleWidth, .flexibleHeight] + toolbar.delegate = self + addSubview(toolbar) + + label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = UIFont.preferredFont(forTextStyle: .headline, scale: 1.1, minimumSize: 18, weight: .semibold) + label.adjustsFontForContentSizeCategory = true + label.textColor = .label + + let dateView = UIView() + dateView.translatesAutoresizingMaskIntoConstraints = false + dateView.clipsToBounds = true + dateView.layer.cornerRadius = 5 + dateView.layer.cornerCurve = .continuous + + dateLabel = UILabel() + dateLabel.translatesAutoresizingMaskIntoConstraints = false + dateLabel.font = UIFont.preferredFont(forTextStyle: .subheadline) + dateLabel.adjustsFontForContentSizeCategory = true + dateLabel.textColor = .secondaryLabel + dateView.addSubview(dateLabel) + + dateToolbar = UIToolbar(frame: bounds) + dateToolbar.autoresizingMask = [.flexibleWidth, .flexibleHeight] + dateView.addSubview(dateToolbar) + + let stackView = UIStackView(arrangedSubviews: [label, UIView(), dateView]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = 15 + stackView.alignment = .firstBaseline + addSubview(stackView) + + NSLayoutConstraint.activate([ + self.heightAnchor.constraint(equalToConstant: 44), + + stackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor, constant: -8), + stackView.topAnchor.constraint(equalTo: self.topAnchor), + stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + + dateLabel.leadingAnchor.constraint(equalTo: dateView.leadingAnchor, constant: 8), + dateLabel.trailingAnchor.constraint(equalTo: dateView.trailingAnchor, constant: -8), + dateLabel.topAnchor.constraint(equalTo: dateView.topAnchor, constant: 4), + dateLabel.bottomAnchor.constraint(equalTo: dateView.bottomAnchor, constant: -4) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func updateToolbar() { + toolbar.alpha = isPinned ? 1 : 0 + dateToolbar.alpha = isPinned ? 0 : 1 + } + +} + +extension SectionDateHeaderView: UIToolbarDelegate { + func position(for bar: UIBarPositioning) -> UIBarPosition { + .top + } +} + +extension NSCollectionLayoutBoundarySupplementaryItem { + static var dateHeader: NSCollectionLayoutBoundarySupplementaryItem { + let item = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), + heightDimension: .estimated(52)), + elementKind: "DateHeader", + alignment: .top) + item.pinToVisibleBounds = true + item.zIndex = .max + return item + } +} diff --git a/Zebra/UI/Common/SectionHeaderView.swift b/Zebra/UI/Common/SectionHeaderView.swift index 914aa62713..392850a2a7 100644 --- a/Zebra/UI/Common/SectionHeaderView.swift +++ b/Zebra/UI/Common/SectionHeaderView.swift @@ -8,7 +8,11 @@ import UIKit -class SectionHeaderView: UICollectionReusableView { +protocol PinnableHeader { + var isPinned: Bool { get set } +} + +class SectionHeaderView: UICollectionReusableView, PinnableHeader { var title: String? { get { label.text } @@ -19,15 +23,28 @@ class SectionHeaderView: UICollectionReusableView { didSet { updateButtons() } } + var isPinned = false { + didSet { updateToolbar() } + } + private var label: UILabel! private var buttonsStackView: UIStackView! + private var toolbar: UIToolbar! override init(frame: CGRect) { super.init(frame: frame) + preservesSuperviewLayoutMargins = true + + toolbar = UIToolbar(frame: bounds) + toolbar.autoresizingMask = [.flexibleWidth, .flexibleHeight] + toolbar.delegate = self + addSubview(toolbar) + label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false - label.font = .preferredFont(forTextStyle: .headline) + label.font = UIFont.preferredFont(forTextStyle: .headline, scale: 1.1, minimumSize: 18, weight: .semibold) + label.adjustsFontForContentSizeCategory = true label.textColor = .label buttonsStackView = UIStackView() @@ -41,10 +58,12 @@ class SectionHeaderView: UICollectionReusableView { addSubview(stackView) NSLayoutConstraint.activate([ + self.heightAnchor.constraint(equalToConstant: 44), + stackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), stackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), - stackView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), - stackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + stackView.topAnchor.constraint(equalTo: self.topAnchor), + stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor) ]) } @@ -63,12 +82,22 @@ class SectionHeaderView: UICollectionReusableView { } } + private func updateToolbar() { + toolbar.alpha = isPinned ? 1 : 0 + } + +} + +extension SectionHeaderView: UIToolbarDelegate { + func position(for bar: UIBarPositioning) -> UIBarPosition { + .top + } } extension NSCollectionLayoutBoundarySupplementaryItem { static var header: NSCollectionLayoutBoundarySupplementaryItem { let item = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), - heightDimension: .absolute(52)), + heightDimension: .estimated(44)), elementKind: "Header", alignment: .top) item.pinToVisibleBounds = true diff --git a/Zebra/UI/Common/WebImageView.swift b/Zebra/UI/Common/WebImageView.swift new file mode 100644 index 0000000000..114fd84dab --- /dev/null +++ b/Zebra/UI/Common/WebImageView.swift @@ -0,0 +1,60 @@ +// +// WebImageView.swift +// Zebra +// +// Created by Adam Demasi on 6/7/2022. +// Copyright © 2022 Zebra Team. All rights reserved. +// + +import UIKit + +class WebImageView: UIImageView { + + private var currentImage: (url: URL?, usingScale: Bool, fallbackImage: UIImage?)? + + private var frameObserver: NSKeyValueObservation! + + override init(frame: CGRect) { + super.init(frame: frame) + + frameObserver = observe(\.frame, options: [.new, .old]) { _, change in + if change.oldValue?.size != change.newValue?.size { + self.reloadImage() + } + } + } + + convenience init() { + self.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + reloadImage() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + reloadImage() + } + + override func load(url: URL?, usingScale: Bool = true, fallbackImage: UIImage? = nil) { + if let currentImage = currentImage, + currentImage == (url, usingScale, fallbackImage) { + return + } + currentImage = (url, usingScale, fallbackImage) + reloadImage() + } + + private func reloadImage() { + if let (url, usingScale, fallbackImage) = currentImage { + super.load(url: url, usingScale: usingScale, fallbackImage: fallbackImage) + } + } + +} diff --git a/Zebra/UI/Errors/ErrorCollectionViewCell.swift b/Zebra/UI/Errors/ErrorCollectionViewCell.swift index 4f5e9c4ff4..6a4b0969c0 100644 --- a/Zebra/UI/Errors/ErrorCollectionViewCell.swift +++ b/Zebra/UI/Errors/ErrorCollectionViewCell.swift @@ -21,6 +21,8 @@ class ErrorCollectionViewCell: UICollectionViewListCell { override init(frame: CGRect) { super.init(frame: frame) + backgroundConfiguration = .clear() + let font = UIFont.preferredFont(forTextStyle: .body) imageView = UIImageView() @@ -42,15 +44,15 @@ class ErrorCollectionViewCell: UICollectionViewListCell { let stackView = UIStackView(arrangedSubviews: [imageView, detailLabel]) stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.spacing = 2 + stackView.spacing = 6 stackView.alignment = .firstBaseline contentView.addSubview(stackView) NSLayoutConstraint.activate([ stackView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), stackView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor, constant: 4), - stackView.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor), - stackView.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor) + stackView.topAnchor.constraint(equalTo: contentView.topAnchor), + stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) ]) } diff --git a/Zebra/UI/PromotedPackagesCarousel/PromotedPackageCarouselItem.swift b/Zebra/UI/PromotedPackagesCarousel/PromotedPackageCarouselItem.swift index cdc846a52e..eada95bc2b 100644 --- a/Zebra/UI/PromotedPackagesCarousel/PromotedPackageCarouselItem.swift +++ b/Zebra/UI/PromotedPackagesCarousel/PromotedPackageCarouselItem.swift @@ -19,7 +19,7 @@ struct PromotedPackagesObject: Codable { // MARK: - Banner -struct PromotedPackageBanner: Codable { +struct PromotedPackageBanner: Codable, Hashable { let title, package: String let url: URL let displayText, hideShadow: Bool? diff --git a/Zebra/UI/PromotedPackagesCarousel/PromotedPackagesFetcher.swift b/Zebra/UI/PromotedPackagesCarousel/PromotedPackagesFetcher.swift index 09ddffe856..ff498fc304 100644 --- a/Zebra/UI/PromotedPackagesCarousel/PromotedPackagesFetcher.swift +++ b/Zebra/UI/PromotedPackagesCarousel/PromotedPackagesFetcher.swift @@ -7,18 +7,68 @@ // import Foundation +import Plains struct PromotedPackagesFetcher { - static func getCached(sourceUUID: String) -> [PromotedPackageBanner]? { - do { - let data = try Data(contentsOf: SourceRefreshController.listsURL/"\(sourceUUID)sileo-featured.json") - let json = try JSONDecoder().decode(PromotedPackagesObject.self, from: data) + private static func getFeaturedItems(sourceUUID: String) -> [PromotedPackageBanner]? { + if let data = try? Data(contentsOf: SourceRefreshController.listsURL/"\(sourceUUID)sileo-featured.json"), + let json = try? JSONDecoder().decode(PromotedPackagesObject.self, from: data), + !json.banners.isEmpty { return json.banners - } catch { - // Ignore error, therefore ignoring the local cache - return nil + .compactMap { item in + guard let url = item.url.secureURL else { + return nil + } + return PromotedPackageBanner(title: item.title, + package: item.package, + url: url, + displayText: item.displayText, + hideShadow: item.hideShadow) + } } + return nil + } + + static func getCached(sourceUUID: String) async -> [PromotedPackageBanner] { + if let items = getFeaturedItems(sourceUUID: sourceUUID), + !items.isEmpty { + return items + } + + // Let’s do our best to find some banners to show. + if let source = SourceManager.shared.source(forUUID: sourceUUID) { + return Array(await PackageManager.shared + .fetchPackages(in: source) { $0["Name"] != nil && $0.headerURL != nil && $0.role == .user && $0.isCompatible } + .compactMap { item in + guard let url = item.headerURL?.secureURL else { + return nil + } + return PromotedPackageBanner(title: item.name, + package: item.identifier, + url: url, + displayText: true, + hideShadow: false) + } + .shuffled() + .safeSubSequence(0..<20)) + } + + return [] + } + + static func getHomeCarouselItems() async -> [PromotedPackageBanner] { + // Combine source featured banners with a random handful of compatible packages with banners. + let featuredItems = SourceManager.shared.sources + .flatMap { getFeaturedItems(sourceUUID: $0.uuid) ?? [] } + .compactMap { PromotedPackageBanner(title: $0.title, + package: $0.package, + url: $0.url, + displayText: true, + hideShadow: false) } + return Array(featuredItems + .shuffled() + .safeSubSequence(0..<20)) } } diff --git a/Zebra/UI/RootViewController.swift b/Zebra/UI/RootViewController.swift index ce95c2673c..dd85fe8296 100644 --- a/Zebra/UI/RootViewController.swift +++ b/Zebra/UI/RootViewController.swift @@ -48,17 +48,15 @@ class RootViewController: UISplitViewController { } static let tabKeyCommands: [UIKeyCommand] = { - var result = [UIKeyCommand]() - for (i, row) in AppTab.allCases.enumerated() { - result.append(UIKeyCommand(title: row.name, - image: row.icon, - action: #selector(switchToTab), - input: "\(i + 1)", - modifierFlags: .command, - propertyList: i, - state: .off)) + AppTab.allCases.enumerated().map { i, row in + UIKeyCommand(title: row.name, + image: row.icon, + action: #selector(switchToTab), + input: "\(i + 1)", + modifierFlags: .command, + propertyList: i, + state: .off) } - return result }() private weak var navigationDelegate: RootViewControllerDelegate?