diff --git a/AppCheck.xcodeproj/project.pbxproj b/AppCheck.xcodeproj/project.pbxproj index e9226f5..55bbda3 100644 --- a/AppCheck.xcodeproj/project.pbxproj +++ b/AppCheck.xcodeproj/project.pbxproj @@ -41,6 +41,7 @@ 54953E6123E0D69A0054345C /* TVCHosts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54953E6023E0D69A0054345C /* TVCHosts.swift */; }; 54953E6F23E44CD00054345C /* TVCHostDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54953E6E23E44CD00054345C /* TVCHostDetails.swift */; }; 54953E7123E473F10054345C /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 54953E7023E473F10054345C /* Settings.bundle */; }; + 549ECD9D24A7AD550097571C /* CustomAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 549ECD9C24A7AD550097571C /* CustomAlert.swift */; }; 54B34594240E6343004C53CC /* TVCFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B34593240E6343004C53CC /* TVCFilter.swift */; }; 54B34596240F0513004C53CC /* TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B34595240F0513004C53CC /* TableView.swift */; }; 54B345A6241BB982004C53CC /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345A5241BB982004C53CC /* Notifications.swift */; }; @@ -140,7 +141,6 @@ 54E540F4247D3F2600F7C34A /* TestDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F3247D3F2600F7C34A /* TestDataSource.swift */; }; 54E540F8247DB90F00F7C34A /* RecordingsDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F7247DB90F00F7C34A /* RecordingsDB.swift */; }; 54E540FA2482414800F7C34A /* SyncUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E540F92482414800F7C34A /* SyncUpdate.swift */; }; - 54EFA4E6248EEE240022D618 /* DatePickerAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54EFA4E5248EEE240022D618 /* DatePickerAlert.swift */; }; 54EFA4E82491A16A0022D618 /* Font.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54EFA4E72491A16A0022D618 /* Font.swift */; }; /* End PBXBuildFile section */ @@ -205,6 +205,7 @@ 54953E6023E0D69A0054345C /* TVCHosts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCHosts.swift; sourceTree = ""; }; 54953E6E23E44CD00054345C /* TVCHostDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCHostDetails.swift; sourceTree = ""; }; 54953E7023E473F10054345C /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; + 549ECD9C24A7AD550097571C /* CustomAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAlert.swift; sourceTree = ""; }; 54B34593240E6343004C53CC /* TVCFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCFilter.swift; sourceTree = ""; }; 54B34595240F0513004C53CC /* TableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableView.swift; sourceTree = ""; }; 54B345A5241BB982004C53CC /* Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = ""; }; @@ -307,7 +308,6 @@ 54E540F3247D3F2600F7C34A /* TestDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDataSource.swift; sourceTree = ""; }; 54E540F7247DB90F00F7C34A /* RecordingsDB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingsDB.swift; sourceTree = ""; }; 54E540F92482414800F7C34A /* SyncUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncUpdate.swift; sourceTree = ""; }; - 54EFA4E5248EEE240022D618 /* DatePickerAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePickerAlert.swift; sourceTree = ""; }; 54EFA4E72491A16A0022D618 /* Font.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Font.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -442,7 +442,7 @@ 545DDDCE243E6267003B6544 /* TutorialSheet.swift */, 54D8B979246C9F2000EB2414 /* FilterPipeline.swift */, 54448A3124899A4000771C96 /* SearchBarManager.swift */, - 54EFA4E5248EEE240022D618 /* DatePickerAlert.swift */, + 549ECD9C24A7AD550097571C /* CustomAlert.swift */, 541FC47524A12D01009154D8 /* IBViews.swift */, ); path = "Common Classes"; @@ -869,7 +869,6 @@ 54D8B98624796E9900EB2414 /* GroupedDomainDataSource.swift in Sources */, 54953E6F23E44CD00054345C /* TVCHostDetails.swift in Sources */, 54D8B97C2471A7E000EB2414 /* String.swift in Sources */, - 54EFA4E6248EEE240022D618 /* DatePickerAlert.swift in Sources */, 54C056DD23E9EEF700214A3F /* BundleIcon.swift in Sources */, 542E2A982404973F001462DC /* TBCMain.swift in Sources */, 54448A3224899A4000771C96 /* SearchBarManager.swift in Sources */, @@ -883,6 +882,7 @@ 54EFA4E82491A16A0022D618 /* Font.swift in Sources */, 54D8B97A246C9F2000EB2414 /* FilterPipeline.swift in Sources */, 54B34594240E6343004C53CC /* TVCFilter.swift in Sources */, + 549ECD9D24A7AD550097571C /* CustomAlert.swift in Sources */, 54E540FA2482414800F7C34A /* SyncUpdate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/main/Common Classes/CustomAlert.swift b/main/Common Classes/CustomAlert.swift new file mode 100644 index 0000000..86694df --- /dev/null +++ b/main/Common Classes/CustomAlert.swift @@ -0,0 +1,241 @@ +import UIKit + +class CustomAlert: UIViewController { + + private let alertTitle: String? + private let alertDetail: String? + + private let customView: CustomView + private var callback: ((CustomView) -> Void)! + + private let backgroundShadow: UIView = { + let shadow = UIView() + shadow.autoresizingMask = [.flexibleWidth, .flexibleHeight] + return shadow + }() + + private let control: UIView = { + let x = UIView() + x.backgroundColor = .sysBackground + return x + }() + + /// Default: `[Cancel, Save]` + let buttonsBar: UIStackView = { + let cancel = QuickUI.button("Cancel", target: self, action: #selector(didTapCancel)) + let save = QuickUI.button("Save", target: self, action: #selector(didTapSave)) + save.titleLabel?.font = save.titleLabel?.font.bold() + let bar = UIStackView(arrangedSubviews: [cancel, save]) + bar.axis = .horizontal + bar.distribution = .equalSpacing + return bar + }() + + + // MARK: - Init + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + init(title: String? = nil, detail: String? = nil, view custom: CustomView) { + alertTitle = title + alertDetail = detail + customView = custom + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .custom + if #available(iOS 13.0, *) { + isModalInPresentation = true + } + } + + internal override func loadView() { + view = UIView() + view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.isHidden = true // otherwise control will flash on present + + var h: CGFloat = 0 + var prevView: UIView? = nil + func appendView(_ x: UIView, top: CGFloat, lr: CGFloat) { + control.addSubview(x) + // sticky edges horizontally + x.anchor([.leading, .trailing], to: control, margin: lr) + // chain views vertically + x.topAnchor =&= (prevView?.bottomAnchor ?? control.topAnchor) + top + prevView = x + h += x.frame.height + top + } + + if let t = alertTitle { + let lbl = QuickUI.label(t, align: .center, style: .headline) + lbl.numberOfLines = 0 + appendView(lbl, top: 16, lr: 16) + } + if let d = alertDetail { + let lbl = QuickUI.label(d, align: .center, style: .subheadline) + lbl.numberOfLines = 0 + appendView(lbl, top: 16, lr: 16) + } + appendView(customView, top: (prevView == nil) ? 0 : 16, lr: 0) + appendView(buttonsBar, top: 0, lr: 25) + + buttonsBar.bottomAnchor =&= control.bottomAnchor - 15 + h += 15 // buttonsBar has 15px padding + + let screen = UIScreen.main.bounds.size + control.frame = CGRect(x: 0, y: screen.height - h, width: screen.width, height: h) + + view.addSubview(control) + control.anchor([.leading, .trailing, .bottom], to: view!) + } + + + // MARK: - User Interaction + + override var keyCommands: [UIKeyCommand]? { + [UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(didTapCancel))] + } + + @objc private func didTapCancel() { + callback = nil + dismiss(animated: true) + } + + @objc private func didTapSave() { + dismiss(animated: true) { + self.callback(self.customView) + self.callback = nil + } + } + + + // MARK: - Present & Dismiss + + func present(in viewController: UIViewController, onSuccess: @escaping (CustomView) -> Void) { + callback = onSuccess + loadViewIfNeeded() + viewController.present(self, animated: false) { + let prev = self.control.frame.origin.y + self.control.frame.origin.y += self.control.frame.height + self.view.isHidden = false + + UIView.animate(withDuration: 0.3) { + self.view.backgroundColor = UIColor.black.withAlphaComponent(0.5) + self.control.frame.origin.y = prev + } + } + } + + override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { + UIView.animate(withDuration: 0.3, animations: { + self.view.backgroundColor = .clear + self.control.frame.origin.y += self.control.frame.height + }) { _ in + super.dismiss(animated: false, completion: completion) + } + } +} + +// ################################### +// # +// # MARK: - Date Picker Alert +// # +// ################################### + +class DatePickerAlert : CustomAlert { + + let datePicker: UIDatePicker = { + let x = UIDatePicker() + x.frame.size.height = x.sizeThatFits(.zero).height + return x + }() + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + init(title: String? = nil, detail: String? = nil, initial date: Date? = nil) { + if let date = date { + datePicker.setDate(date, animated: false) + } + super.init(title: title, detail: detail, view: datePicker) + + let now = QuickUI.button("Now", target: self, action: #selector(didTapNow)) + now.titleLabel?.font = now.titleLabel?.font.bold() + now.setTitleColor(.sysLabel, for: .normal) + buttonsBar.insertArrangedSubview(now, at: 1) + } + + @objc private func didTapNow() { + datePicker.date = Date() + } + + func present(in viewController: UIViewController, onSuccess: @escaping (Date) -> Void) { + super.present(in: viewController) { + onSuccess($0.date) + } + } +} + +// ####################################### +// # +// # MARK: - Duration Picker Alert +// # +// ####################################### + +class DurationPickerAlert: CustomAlert, UIPickerViewDataSource, UIPickerViewDelegate { + + private let dataSource: [[String]] + private let compWidths: [CGFloat] + let pickerView: UIPickerView = { + let x = UIPickerView() + x.frame.size.height = x.sizeThatFits(.zero).height + return x + }() + + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + /// - Parameter options: [[List of labels] per component] + /// - Parameter widths: If `nil` set all components to equal width + init(title: String? = nil, detail: String? = nil, options: [[String]], widths: [CGFloat]? = nil) { + assert(widths == nil || widths!.count == options.count, "widths.count != options.count") + + dataSource = options + compWidths = widths ?? options.map { _ in 1 / CGFloat(options.count) } + + super.init(title: title, detail: detail, view: pickerView) + + pickerView.dataSource = self + pickerView.delegate = self + } + + func numberOfComponents(in _: UIPickerView) -> Int { + dataSource.count + } + func pickerView(_: UIPickerView, numberOfRowsInComponent c: Int) -> Int { + dataSource[c].count + } + func pickerView(_: UIPickerView, titleForRow r: Int, forComponent c: Int) -> String? { + dataSource[c][r] + } + func pickerView(_ pickerView: UIPickerView, widthForComponent c: Int) -> CGFloat { + compWidths[c] * pickerView.frame.width + } + + func present(in viewController: UIViewController, onSuccess: @escaping ([Int]) -> Void) { + super.present(in: viewController) { + onSuccess($0.selection) + } + } +} + +extension UIPickerView { + var selection: [Int] { + get { (0.. Void - private let picker: UIDatePicker = { - let x = UIDatePicker() - let h = x.sizeThatFits(.zero).height - x.frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: h) - return x - }() - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - @discardableResult required init(presentIn viewController: UIViewController, configure: ((UIDatePicker) -> Void)? = nil, onSuccess: @escaping (Date) -> Void) { - callback = onSuccess - super.init(nibName: nil, bundle: nil) - modalPresentationStyle = .custom - if #available(iOS 13.0, *) { - isModalInPresentation = true - } - presentIn(viewController, configure) - } - - internal override func loadView() { - let cancel = QuickUI.button("Discard", target: self, action: #selector(didTapCancel)) - let save = QuickUI.button("Save", target: self, action: #selector(didTapSave)) - let now = QuickUI.button("Now", target: self, action: #selector(didTapNow)) - save.titleLabel?.font = save.titleLabel?.font.bold() - now.titleLabel?.font = now.titleLabel?.font.bold() - now.setTitleColor(.sysLabel, for: .normal) - //cancel.setTitleColor(.systemRed, for: .normal) - - let buttons = UIStackView(arrangedSubviews: [cancel, now, save]) - buttons.axis = .horizontal - buttons.distribution = .equalSpacing - - let bg = UIView(frame: picker.frame) - bg.frame.size.height += buttons.frame.height + 15 - bg.frame.origin.y = UIScreen.main.bounds.height - bg.frame.height - 15 - bg.backgroundColor = .sysBackground - bg.addSubview(picker) - bg.addSubview(buttons) - - let clearBg = UIView() - clearBg.autoresizingMask = [.flexibleWidth, .flexibleHeight] - clearBg.addSubview(bg) - - picker.anchor([.leading, .trailing, .top], to: bg) - picker.bottomAnchor =&= buttons.topAnchor - buttons.anchor([.leading, .trailing], to: bg, margin: 25) - buttons.bottomAnchor =&= bg.bottomAnchor - 15 - bg.anchor([.leading, .trailing, .bottom], to: clearBg) - - view = clearBg - view.isHidden = true // otherwise picker will flash on present - } - - @objc private func didTapNow() { - picker.date = Date() - } - - @objc private func didTapSave() { - dismiss(animated: true) { - self.callback(self.picker.date) - } - } - - @objc private func didTapCancel() { - dismiss(animated: true) - } - - private func presentIn(_ viewController: UIViewController, _ configure: ((UIDatePicker) -> Void)? = nil) { - viewController.present(self, animated: false) { - let control = self.view.subviews.first! - let prev = control.frame.origin.y - control.frame.origin.y += control.frame.height - self.view.isHidden = false - - configure?(self.picker) - - UIView.animate(withDuration: 0.3) { - self.view.backgroundColor = UIColor.black.withAlphaComponent(0.5) - control.frame.origin.y = prev - } - } - } - - override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { - UIView.animate(withDuration: 0.3, animations: { - let control = self.view.subviews.first! - self.view.backgroundColor = .clear - control.frame.origin.y += control.frame.height - }) { _ in - super.dismiss(animated: false, completion: completion) - } - } -} diff --git a/main/Common Classes/QuickUI.swift b/main/Common Classes/QuickUI.swift index 1d70f6c..184360f 100644 --- a/main/Common Classes/QuickUI.swift +++ b/main/Common Classes/QuickUI.swift @@ -2,6 +2,17 @@ import UIKit struct QuickUI { + static func label(_ str: String, frame: CGRect = CGRect.zero, align: NSTextAlignment = .natural, style: UIFont.TextStyle = .body) -> UILabel { + let x = UILabel(frame: frame) + x.text = str + x.textAlignment = align + x.font = .preferredFont(forTextStyle: style) + if #available(iOS 10.0, *) { + x.adjustsFontForContentSizeCategory = true + } + return x + } + static func button(_ title: String, target: Any? = nil, action: Selector? = nil) -> UIButton { let x = UIButton(type: .roundedRect) x.setTitle(title, for: .normal) diff --git a/main/Requests/VCDateFilter.swift b/main/Requests/VCDateFilter.swift index d49ee47..900d679 100644 --- a/main/Requests/VCDateFilter.swift +++ b/main/Requests/VCDateFilter.swift @@ -63,10 +63,9 @@ class VCDateFilter: UIViewController, UIGestureRecognizerDelegate { @IBAction private func didTapRangeButton(_ sender: UIButton) { let flag = (sender == buttonRangeStart) - DatePickerAlert(presentIn: self, configure: { - $0.setDate(Date(flag ? self.tsRangeA : self.tsRangeB), animated: false) - }, onSuccess: { - var ts = $0.timestamp + let oldDate = flag ? Date(self.tsRangeA) : Date(self.tsRangeB) + DatePickerAlert(initial: oldDate).present(in: self) { (selected: Date) in + var ts = selected.timestamp ts -= ts % 60 // remove seconds // if one of these is greater than the other, adjust the latter too. if flag || self.tsRangeA > ts { @@ -77,7 +76,7 @@ class VCDateFilter: UIViewController, UIGestureRecognizerDelegate { self.tsRangeB = ts + 59 // upper end of minute self.buttonRangeEnd.setTitle(DateFormat.minutes(ts + 59), for: .normal) } - }) + } } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {