mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-10 07:13:28 +01:00
XCode project for app, moved app project to folder
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
//
|
||||
// AppBannerView.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/29/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
import RoxasUIKit
|
||||
|
||||
class AppBannerView: RSTNibView {
|
||||
override var accessibilityLabel: String? {
|
||||
get { self.accessibilityView?.accessibilityLabel }
|
||||
set { self.accessibilityView?.accessibilityLabel = newValue }
|
||||
}
|
||||
|
||||
override open var accessibilityAttributedLabel: NSAttributedString? {
|
||||
get { self.accessibilityView?.accessibilityAttributedLabel }
|
||||
set { self.accessibilityView?.accessibilityAttributedLabel = newValue }
|
||||
}
|
||||
|
||||
override var accessibilityValue: String? {
|
||||
get { self.accessibilityView?.accessibilityValue }
|
||||
set { self.accessibilityView?.accessibilityValue = newValue }
|
||||
}
|
||||
|
||||
override open var accessibilityAttributedValue: NSAttributedString? {
|
||||
get { self.accessibilityView?.accessibilityAttributedValue }
|
||||
set { self.accessibilityView?.accessibilityAttributedValue = newValue }
|
||||
}
|
||||
|
||||
override open var accessibilityTraits: UIAccessibilityTraits {
|
||||
get { accessibilityView?.accessibilityTraits ?? [] }
|
||||
set { accessibilityView?.accessibilityTraits = newValue }
|
||||
}
|
||||
|
||||
private var originalTintColor: UIColor?
|
||||
|
||||
@IBOutlet var titleLabel: UILabel!
|
||||
@IBOutlet var subtitleLabel: UILabel!
|
||||
@IBOutlet var iconImageView: AppIconImageView!
|
||||
@IBOutlet var button: PillButton!
|
||||
@IBOutlet var buttonLabel: UILabel!
|
||||
@IBOutlet var betaBadgeView: UIView!
|
||||
|
||||
@IBOutlet var backgroundEffectView: UIVisualEffectView!
|
||||
|
||||
@IBOutlet private var vibrancyView: UIVisualEffectView!
|
||||
@IBOutlet private var accessibilityView: UIView!
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
private func initialize() {
|
||||
accessibilityView.accessibilityTraits.formUnion(.button)
|
||||
|
||||
isAccessibilityElement = false
|
||||
accessibilityElements = [accessibilityView, button].compactMap { $0 }
|
||||
|
||||
betaBadgeView.isHidden = true
|
||||
}
|
||||
|
||||
override func tintColorDidChange() {
|
||||
super.tintColorDidChange()
|
||||
|
||||
if tintAdjustmentMode != .dimmed {
|
||||
originalTintColor = tintColor
|
||||
}
|
||||
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
extension AppBannerView {
|
||||
func configure(for app: AppProtocol) {
|
||||
struct AppValues {
|
||||
var name: String
|
||||
var developerName: String?
|
||||
var isBeta: Bool = false
|
||||
|
||||
init(app: AppProtocol) {
|
||||
name = app.name
|
||||
|
||||
guard let storeApp = (app as? StoreApp) ?? (app as? InstalledApp)?.storeApp else { return }
|
||||
developerName = storeApp.developerName
|
||||
|
||||
if storeApp.isBeta {
|
||||
name = String(format: NSLocalizedString("%@ beta", comment: ""), app.name)
|
||||
isBeta = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let values = AppValues(app: app)
|
||||
titleLabel.text = app.name // Don't use values.name since that already includes "beta".
|
||||
betaBadgeView.isHidden = !values.isBeta
|
||||
|
||||
if let developerName = values.developerName {
|
||||
subtitleLabel.text = developerName
|
||||
accessibilityLabel = String(format: NSLocalizedString("%@ by %@", comment: ""), values.name, developerName)
|
||||
} else {
|
||||
subtitleLabel.text = NSLocalizedString("Sideloaded", comment: "")
|
||||
accessibilityLabel = values.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppBannerView {
|
||||
func update() {
|
||||
clipsToBounds = true
|
||||
layer.cornerRadius = 22
|
||||
|
||||
subtitleLabel.textColor = originalTintColor ?? tintColor
|
||||
backgroundEffectView.backgroundColor = originalTintColor ?? tintColor
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// AppIconImageView.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/9/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class AppIconImageView: UIImageView {
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
contentMode = .scaleAspectFill
|
||||
clipsToBounds = true
|
||||
|
||||
backgroundColor = .white
|
||||
|
||||
if #available(iOS 13, *) {
|
||||
self.layer.cornerCurve = .continuous
|
||||
} else {
|
||||
if layer.responds(to: Selector(("continuousCorners"))) {
|
||||
layer.setValue(true, forKey: "continuousCorners")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
// Based off of 60pt icon having 12pt radius.
|
||||
let radius = bounds.height / 5
|
||||
layer.cornerRadius = radius
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// BackgroundTaskManager.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 6/19/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
|
||||
public final class BackgroundTaskManager {
|
||||
public static let shared = BackgroundTaskManager()
|
||||
|
||||
private var isPlaying = false
|
||||
|
||||
private let audioEngine: AVAudioEngine
|
||||
private let player: AVAudioPlayerNode
|
||||
private let audioFile: AVAudioFile
|
||||
|
||||
private let audioEngineQueue: DispatchQueue
|
||||
|
||||
private init() {
|
||||
audioEngine = AVAudioEngine()
|
||||
audioEngine.mainMixerNode.outputVolume = 0.0
|
||||
|
||||
player = AVAudioPlayerNode()
|
||||
audioEngine.attach(player)
|
||||
|
||||
do {
|
||||
let audioFileURL = Bundle.main.url(forResource: "Silence", withExtension: "m4a")!
|
||||
|
||||
audioFile = try AVAudioFile(forReading: audioFileURL)
|
||||
audioEngine.connect(player, to: audioEngine.mainMixerNode, format: audioFile.processingFormat)
|
||||
} catch {
|
||||
fatalError("Error. \(error)")
|
||||
}
|
||||
|
||||
audioEngineQueue = DispatchQueue(label: "com.altstore.BackgroundTaskManager")
|
||||
}
|
||||
}
|
||||
|
||||
public extension BackgroundTaskManager {
|
||||
func performExtendedBackgroundTask(taskHandler: @escaping ((Result<Void, Error>, @escaping () -> Void) -> Void)) {
|
||||
func finish() {
|
||||
player.stop()
|
||||
audioEngine.stop()
|
||||
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
audioEngineQueue.sync {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, options: .mixWithOthers)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
|
||||
// Schedule audio file buffers.
|
||||
self.scheduleAudioFile()
|
||||
self.scheduleAudioFile()
|
||||
|
||||
let outputFormat = self.audioEngine.outputNode.outputFormat(forBus: 0)
|
||||
self.audioEngine.connect(self.audioEngine.mainMixerNode, to: self.audioEngine.outputNode, format: outputFormat)
|
||||
|
||||
try self.audioEngine.start()
|
||||
self.player.play()
|
||||
|
||||
self.isPlaying = true
|
||||
|
||||
taskHandler(.success(())) {
|
||||
finish()
|
||||
}
|
||||
} catch {
|
||||
taskHandler(.failure(error)) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension BackgroundTaskManager {
|
||||
func scheduleAudioFile() {
|
||||
player.scheduleFile(audioFile, at: nil) {
|
||||
self.audioEngineQueue.async {
|
||||
guard self.isPlaying else { return }
|
||||
self.scheduleAudioFile()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// BannerCollectionViewCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 3/23/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class BannerCollectionViewCell: UICollectionViewCell {
|
||||
private(set) var errorBadge: UIView?
|
||||
@IBOutlet private(set) var bannerView: AppBannerView!
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
contentView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
if #available(iOS 13.0, *) {
|
||||
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([
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
57
SideStoreApp/Sources/SideStoreAppKit/Components/Button.swift
Normal file
57
SideStoreApp/Sources/SideStoreAppKit/Components/Button.swift
Normal file
@@ -0,0 +1,57 @@
|
||||
//
|
||||
// Button.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/9/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class Button: UIButton {
|
||||
override var intrinsicContentSize: CGSize {
|
||||
var size = super.intrinsicContentSize
|
||||
size.width += 20
|
||||
size.height += 10
|
||||
return size
|
||||
}
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
setTitleColor(.white, for: .normal)
|
||||
|
||||
layer.masksToBounds = true
|
||||
layer.cornerRadius = 8
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override func tintColorDidChange() {
|
||||
super.tintColorDidChange()
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override var isHighlighted: Bool {
|
||||
didSet {
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
|
||||
override var isEnabled: Bool {
|
||||
didSet {
|
||||
update()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Button {
|
||||
func update() {
|
||||
if isEnabled {
|
||||
backgroundColor = tintColor
|
||||
} else {
|
||||
backgroundColor = .lightGray
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//
|
||||
// CollapsingTextView.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/23/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class CollapsingTextView: UITextView {
|
||||
var isCollapsed = true {
|
||||
didSet {
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
var maximumNumberOfLines = 2 {
|
||||
didSet {
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
var lineSpacing: CGFloat = 2 {
|
||||
didSet {
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
let moreButton = UIButton(type: .system)
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
layoutManager.delegate = self
|
||||
|
||||
textContainerInset = .zero
|
||||
textContainer.lineFragmentPadding = 0
|
||||
textContainer.lineBreakMode = .byTruncatingTail
|
||||
textContainer.heightTracksTextView = true
|
||||
textContainer.widthTracksTextView = true
|
||||
|
||||
moreButton.setTitle(NSLocalizedString("More", comment: ""), for: .normal)
|
||||
moreButton.addTarget(self, action: #selector(CollapsingTextView.toggleCollapsed(_:)), for: .primaryActionTriggered)
|
||||
addSubview(moreButton)
|
||||
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
guard let font = font else { return }
|
||||
|
||||
let buttonFont = UIFont.systemFont(ofSize: font.pointSize, weight: .medium)
|
||||
moreButton.titleLabel?.font = buttonFont
|
||||
|
||||
let buttonY = (font.lineHeight + lineSpacing) * CGFloat(maximumNumberOfLines - 1)
|
||||
let size = moreButton.sizeThatFits(CGSize(width: 1000, height: 1000))
|
||||
|
||||
let moreButtonFrame = CGRect(x: bounds.width - moreButton.bounds.width,
|
||||
y: buttonY,
|
||||
width: size.width,
|
||||
height: font.lineHeight)
|
||||
moreButton.frame = moreButtonFrame
|
||||
|
||||
if isCollapsed {
|
||||
textContainer.maximumNumberOfLines = maximumNumberOfLines
|
||||
|
||||
let boundingSize = attributedText.boundingRect(with: CGSize(width: textContainer.size.width, height: .infinity), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
|
||||
let maximumCollapsedHeight = font.lineHeight * Double(maximumNumberOfLines)
|
||||
|
||||
if boundingSize.height.rounded() > maximumCollapsedHeight.rounded() {
|
||||
var exclusionFrame = moreButtonFrame
|
||||
exclusionFrame.origin.y += moreButton.bounds.midY
|
||||
exclusionFrame.size.width = bounds.width // Extra wide to make sure it wraps to next line.
|
||||
textContainer.exclusionPaths = [UIBezierPath(rect: exclusionFrame)]
|
||||
|
||||
moreButton.isHidden = false
|
||||
} else {
|
||||
textContainer.exclusionPaths = []
|
||||
|
||||
moreButton.isHidden = true
|
||||
}
|
||||
} else {
|
||||
textContainer.maximumNumberOfLines = 0
|
||||
textContainer.exclusionPaths = []
|
||||
|
||||
moreButton.isHidden = true
|
||||
}
|
||||
|
||||
invalidateIntrinsicContentSize()
|
||||
}
|
||||
}
|
||||
|
||||
private extension CollapsingTextView {
|
||||
@objc func toggleCollapsed(_: UIButton) {
|
||||
isCollapsed.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
extension CollapsingTextView: NSLayoutManagerDelegate {
|
||||
func layoutManager(_: NSLayoutManager, lineSpacingAfterGlyphAt _: Int, withProposedLineFragmentRect _: CGRect) -> CGFloat {
|
||||
lineSpacing
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// ForwardingNavigationController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 10/24/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class ForwardingNavigationController: UINavigationController {
|
||||
override var childForStatusBarStyle: UIViewController? {
|
||||
self.topViewController
|
||||
}
|
||||
|
||||
override var childForStatusBarHidden: UIViewController? {
|
||||
topViewController
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//
|
||||
// NavigationBar.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/15/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import RoxasUIKit
|
||||
|
||||
final class NavigationBar: UINavigationBar {
|
||||
@IBInspectable var automaticallyAdjustsItemPositions: Bool = true
|
||||
|
||||
private let backgroundColorView = UIView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
super.init(coder: aDecoder)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
private func initialize() {
|
||||
if #available(iOS 13, *) {
|
||||
let standardAppearance = UINavigationBarAppearance()
|
||||
standardAppearance.configureWithDefaultBackground()
|
||||
standardAppearance.shadowColor = nil
|
||||
|
||||
let edgeAppearance = UINavigationBarAppearance()
|
||||
edgeAppearance.configureWithOpaqueBackground()
|
||||
edgeAppearance.backgroundColor = self.barTintColor
|
||||
edgeAppearance.shadowColor = nil
|
||||
|
||||
if let tintColor = self.barTintColor {
|
||||
let textAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white]
|
||||
|
||||
standardAppearance.backgroundColor = tintColor
|
||||
standardAppearance.titleTextAttributes = textAttributes
|
||||
standardAppearance.largeTitleTextAttributes = textAttributes
|
||||
|
||||
edgeAppearance.titleTextAttributes = textAttributes
|
||||
edgeAppearance.largeTitleTextAttributes = textAttributes
|
||||
} else {
|
||||
standardAppearance.backgroundColor = nil
|
||||
}
|
||||
|
||||
self.scrollEdgeAppearance = edgeAppearance
|
||||
self.standardAppearance = standardAppearance
|
||||
} else {
|
||||
shadowImage = UIImage()
|
||||
|
||||
if let tintColor = barTintColor {
|
||||
backgroundColorView.backgroundColor = tintColor
|
||||
|
||||
// Top = -50 to cover status bar area above navigation bar on any device.
|
||||
// Bottom = -1 to prevent a flickering gray line from appearing.
|
||||
addSubview(backgroundColorView, pinningEdgesWith: UIEdgeInsets(top: -50, left: 0, bottom: -1, right: 0))
|
||||
} else {
|
||||
barTintColor = .white
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
if backgroundColorView.superview != nil {
|
||||
insertSubview(backgroundColorView, at: 1)
|
||||
}
|
||||
|
||||
if automaticallyAdjustsItemPositions {
|
||||
// We can't easily shift just the back button up, so we shift the entire content view slightly.
|
||||
for contentView in subviews {
|
||||
guard NSStringFromClass(type(of: contentView)).contains("ContentView") else { continue }
|
||||
contentView.center.y -= 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
172
SideStoreApp/Sources/SideStoreAppKit/Components/PillButton.swift
Normal file
172
SideStoreApp/Sources/SideStoreAppKit/Components/PillButton.swift
Normal file
@@ -0,0 +1,172 @@
|
||||
//
|
||||
// PillButton.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/15/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class PillButton: UIButton {
|
||||
override var accessibilityValue: String? {
|
||||
get {
|
||||
guard progress != nil else { return super.accessibilityValue }
|
||||
return progressView.accessibilityValue
|
||||
}
|
||||
set { super.accessibilityValue = newValue }
|
||||
}
|
||||
|
||||
var progress: Progress? {
|
||||
didSet {
|
||||
progressView.progress = Float(progress?.fractionCompleted ?? 0)
|
||||
progressView.observedProgress = progress
|
||||
|
||||
let isUserInteractionEnabled = self.isUserInteractionEnabled
|
||||
isIndicatingActivity = (progress != nil)
|
||||
self.isUserInteractionEnabled = isUserInteractionEnabled
|
||||
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
var progressTintColor: UIColor? {
|
||||
get {
|
||||
progressView.progressTintColor
|
||||
}
|
||||
set {
|
||||
progressView.progressTintColor = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var countdownDate: Date? {
|
||||
didSet {
|
||||
isEnabled = (countdownDate == nil)
|
||||
displayLink.isPaused = (countdownDate == nil)
|
||||
|
||||
if countdownDate == nil {
|
||||
setTitle(nil, for: .disabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let progressView = UIProgressView(progressViewStyle: .default)
|
||||
|
||||
private lazy var displayLink: CADisplayLink = {
|
||||
let displayLink = CADisplayLink(target: self, selector: #selector(PillButton.updateCountdown))
|
||||
displayLink.preferredFramesPerSecond = 15
|
||||
displayLink.isPaused = true
|
||||
displayLink.add(to: .main, forMode: .common)
|
||||
return displayLink
|
||||
}()
|
||||
|
||||
private let dateComponentsFormatter: DateComponentsFormatter = {
|
||||
let dateComponentsFormatter = DateComponentsFormatter()
|
||||
dateComponentsFormatter.zeroFormattingBehavior = [.pad]
|
||||
dateComponentsFormatter.collapsesLargestUnit = false
|
||||
return dateComponentsFormatter
|
||||
}()
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
var size = super.intrinsicContentSize
|
||||
size.width += 26
|
||||
size.height += 3
|
||||
return size
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.displayLink.remove(from: .main, forMode: RunLoop.Mode.default)
|
||||
}
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
layer.masksToBounds = true
|
||||
accessibilityTraits.formUnion([.updatesFrequently, .button])
|
||||
|
||||
activityIndicatorView.style = .medium
|
||||
activityIndicatorView.isUserInteractionEnabled = false
|
||||
|
||||
progressView.progress = 0
|
||||
progressView.trackImage = UIImage()
|
||||
progressView.isUserInteractionEnabled = false
|
||||
addSubview(progressView)
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
progressView.bounds.size.width = bounds.width
|
||||
|
||||
let scale = bounds.height / progressView.bounds.height
|
||||
|
||||
progressView.transform = CGAffineTransform.identity.scaledBy(x: 1, y: scale)
|
||||
progressView.center = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||
|
||||
layer.cornerRadius = bounds.midY
|
||||
}
|
||||
|
||||
override func tintColorDidChange() {
|
||||
super.tintColorDidChange()
|
||||
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
private extension PillButton {
|
||||
func update() {
|
||||
if progress == nil {
|
||||
setTitleColor(.white, for: .normal)
|
||||
backgroundColor = tintColor
|
||||
} else {
|
||||
setTitleColor(tintColor, for: .normal)
|
||||
backgroundColor = tintColor.withAlphaComponent(0.15)
|
||||
}
|
||||
|
||||
progressView.progressTintColor = tintColor
|
||||
}
|
||||
|
||||
@objc func updateCountdown() {
|
||||
guard let endDate = countdownDate else { return }
|
||||
|
||||
let startDate = Date()
|
||||
|
||||
let interval = endDate.timeIntervalSince(startDate)
|
||||
guard interval > 0 else {
|
||||
isEnabled = true
|
||||
return
|
||||
}
|
||||
|
||||
let text: String?
|
||||
|
||||
if interval < (1 * 60 * 60) {
|
||||
dateComponentsFormatter.unitsStyle = .positional
|
||||
dateComponentsFormatter.allowedUnits = [.minute, .second]
|
||||
|
||||
text = dateComponentsFormatter.string(from: startDate, to: endDate)
|
||||
} else if interval < (2 * 24 * 60 * 60) {
|
||||
dateComponentsFormatter.unitsStyle = .positional
|
||||
dateComponentsFormatter.allowedUnits = [.hour, .minute, .second]
|
||||
|
||||
text = dateComponentsFormatter.string(from: startDate, to: endDate)
|
||||
} else {
|
||||
dateComponentsFormatter.unitsStyle = .full
|
||||
dateComponentsFormatter.allowedUnits = [.day]
|
||||
|
||||
let numberOfDays = endDate.numberOfCalendarDays(since: startDate)
|
||||
text = String(format: NSLocalizedString("%@ DAYS", comment: ""), NSNumber(value: numberOfDays))
|
||||
}
|
||||
|
||||
if let text = text {
|
||||
UIView.performWithoutAnimation {
|
||||
self.isEnabled = false
|
||||
self.setTitle(text, for: .disabled)
|
||||
self.layoutIfNeeded()
|
||||
}
|
||||
} else {
|
||||
isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// TextCollectionReusableView.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 3/23/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class TextCollectionReusableView: UICollectionReusableView {
|
||||
@IBOutlet var textLabel: UILabel!
|
||||
|
||||
@IBOutlet var topLayoutConstraint: NSLayoutConstraint!
|
||||
@IBOutlet var bottomLayoutConstraint: NSLayoutConstraint!
|
||||
@IBOutlet var leadingLayoutConstraint: NSLayoutConstraint!
|
||||
@IBOutlet var trailingLayoutConstraint: NSLayoutConstraint!
|
||||
}
|
||||
115
SideStoreApp/Sources/SideStoreAppKit/Components/ToastView.swift
Normal file
115
SideStoreApp/Sources/SideStoreAppKit/Components/ToastView.swift
Normal file
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// ToastView.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/19/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import RoxasUIKit
|
||||
import Shared
|
||||
import SideStoreCore
|
||||
import SideKit
|
||||
import AltSign
|
||||
|
||||
extension TimeInterval {
|
||||
static let shortToastViewDuration = 4.0
|
||||
static let longToastViewDuration = 8.0
|
||||
}
|
||||
|
||||
final class ToastView: RSTToastView {
|
||||
var preferredDuration: TimeInterval
|
||||
|
||||
override init(text: String, detailText detailedText: String?) {
|
||||
if detailedText == nil {
|
||||
preferredDuration = .shortToastViewDuration
|
||||
} else {
|
||||
preferredDuration = .longToastViewDuration
|
||||
}
|
||||
|
||||
super.init(text: text, detailText: detailedText)
|
||||
|
||||
isAccessibilityElement = true
|
||||
|
||||
layoutMargins = UIEdgeInsets(top: 8, left: 16, bottom: 10, right: 16)
|
||||
setNeedsLayout()
|
||||
|
||||
if let stackView = textLabel.superview as? UIStackView {
|
||||
// RSTToastView does not expose stack view containing labels,
|
||||
// so we access it indirectly as the labels' superview.
|
||||
stackView.spacing = (detailedText != nil) ? 4.0 : 0.0
|
||||
}
|
||||
}
|
||||
|
||||
convenience init(error: Error) {
|
||||
var error = error as NSError
|
||||
var underlyingError = error.underlyingError
|
||||
|
||||
var preferredDuration: TimeInterval?
|
||||
|
||||
if
|
||||
let unwrappedUnderlyingError = underlyingError,
|
||||
error.domain == AltServerErrorDomain && error.code == -1 //ALTServerError.underlyingError().rawValue
|
||||
{
|
||||
// Treat underlyingError as the primary error.
|
||||
|
||||
error = unwrappedUnderlyingError as NSError
|
||||
underlyingError = nil
|
||||
|
||||
preferredDuration = .longToastViewDuration
|
||||
}
|
||||
|
||||
let text: String
|
||||
let detailText: String?
|
||||
|
||||
if let failure = error.localizedFailure {
|
||||
text = failure
|
||||
detailText = error.localizedFailureReason ?? error.localizedRecoverySuggestion ?? underlyingError?.localizedDescription ?? error.localizedDescription
|
||||
} else if let reason = error.localizedFailureReason {
|
||||
text = reason
|
||||
detailText = error.localizedRecoverySuggestion ?? underlyingError?.localizedDescription
|
||||
} else {
|
||||
text = error.localizedDescription
|
||||
detailText = underlyingError?.localizedDescription ?? error.localizedRecoverySuggestion
|
||||
}
|
||||
|
||||
self.init(text: text, detailText: detailText)
|
||||
|
||||
if let preferredDuration = preferredDuration {
|
||||
self.preferredDuration = preferredDuration
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
// Rough calculation to determine height of ToastView with one-line textLabel.
|
||||
let minimumHeight = textLabel.font.lineHeight.rounded() + 18
|
||||
layer.cornerRadius = minimumHeight / 2
|
||||
}
|
||||
|
||||
func show(in viewController: UIViewController) {
|
||||
show(in: viewController.navigationController?.view ?? viewController.view, duration: preferredDuration)
|
||||
}
|
||||
|
||||
override func show(in view: UIView, duration: TimeInterval) {
|
||||
super.show(in: view, duration: duration)
|
||||
|
||||
let announcement = (textLabel.text ?? "") + ". " + (detailTextLabel.text ?? "")
|
||||
accessibilityLabel = announcement
|
||||
|
||||
// Minimum 0.75 delay to prevent announcement being cut off by VoiceOver.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
|
||||
UIAccessibility.post(notification: .announcement, argument: announcement)
|
||||
}
|
||||
}
|
||||
|
||||
override func show(in view: UIView) {
|
||||
show(in: view, duration: preferredDuration)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user