Recordings: Choose app instead of custom title
This commit is contained in:
52
main/Recordings/App Icons/AppStoreSearch.swift
Normal file
52
main/Recordings/App Icons/AppStoreSearch.swift
Normal file
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
extension URL {
|
||||
static func appStoreSearch(query: String) -> URL {
|
||||
// https://itunes.apple.com/lookup?bundleId=...
|
||||
URL.make("https://itunes.apple.com/search", params: [
|
||||
"media" : "software",
|
||||
"limit" : "25",
|
||||
"country" : NSLocale.current.regionCode ?? "DE",
|
||||
"version" : "2",
|
||||
"term" : query,
|
||||
])!
|
||||
}
|
||||
}
|
||||
|
||||
struct AppStoreSearch {
|
||||
struct Result {
|
||||
let bundleId, name: String
|
||||
let developer, imageURL: String?
|
||||
}
|
||||
|
||||
static func search(_ term: String, _ closure: @escaping ([Result]?) -> Void) {
|
||||
URLSession.shared.dataTask(with: .init(url: .appStoreSearch(query: term))) { data, response, error in
|
||||
guard let data = data, error == nil,
|
||||
let response = response as? HTTPURLResponse,
|
||||
(200 ..< 300) ~= response.statusCode else {
|
||||
closure(nil)
|
||||
return
|
||||
}
|
||||
closure(jsonSearchToList(data))
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private static func jsonSearchToList(_ data: Data) -> [Result]? {
|
||||
guard let json = (try? JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)) as? [String: Any],
|
||||
let resAll = json["results"] as? [Any] else {
|
||||
return nil
|
||||
}
|
||||
return resAll.compactMap {
|
||||
guard let res = $0 as? [String: Any],
|
||||
let bndl = res["bundleId"] as? String,
|
||||
let name = res["trackName"] as? String // trackCensoredName
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let seller = res["sellerName"] as? String // artistName
|
||||
let image = res["artworkUrl60"] as? String // artworkUrl100
|
||||
return Result(bundleId: bndl, name: name, developer: seller, imageURL: image)
|
||||
}
|
||||
}
|
||||
}
|
||||
106
main/Recordings/App Icons/BundleIcon.swift
Normal file
106
main/Recordings/App Icons/BundleIcon.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
import UIKit
|
||||
|
||||
extension CGContext {
|
||||
func lineFromTo(x1: CGFloat, y1: CGFloat, x2: CGFloat, y2: CGFloat) {
|
||||
self.move(to: CGPoint(x: x1, y: y1))
|
||||
self.addLine(to: CGPoint(x: x2, y: y2))
|
||||
}
|
||||
}
|
||||
|
||||
struct BundleIcon {
|
||||
|
||||
static let unknown : UIImage? = {
|
||||
let rect = CGRect(x: 0, y: 0, width: 30, height: 30)
|
||||
UIGraphicsBeginImageContextWithOptions(rect.size, false, 0.0)
|
||||
|
||||
let context = UIGraphicsGetCurrentContext()!
|
||||
let lineWidth: CGFloat = 0.5
|
||||
let corner: CGFloat = 6.75
|
||||
let c = corner / CGFloat.pi + lineWidth/2
|
||||
let sz: CGFloat = rect.height
|
||||
let m = sz / 2
|
||||
let r1 = 0.2 * sz, r2 = sqrt(2 * r1 * r1)
|
||||
|
||||
// diagonal
|
||||
context.lineFromTo(x1: c, y1: c, x2: sz-c, y2: sz-c)
|
||||
context.lineFromTo(x1: c, y1: sz-c, x2: sz-c, y2: c)
|
||||
// horizontal
|
||||
context.lineFromTo(x1: 0, y1: m, x2: sz, y2: m)
|
||||
context.lineFromTo(x1: 0, y1: m + r1, x2: sz, y2: m + r1)
|
||||
context.lineFromTo(x1: 0, y1: m - r1, x2: sz, y2: m - r1)
|
||||
// vertical
|
||||
context.lineFromTo(x1: m, y1: 0, x2: m, y2: sz)
|
||||
context.lineFromTo(x1: m + r1, y1: 0, x2: m + r1, y2: sz)
|
||||
context.lineFromTo(x1: m - r1, y1: 0, x2: m - r1, y2: sz)
|
||||
// circles
|
||||
context.addEllipse(in: CGRect(x: m - r1, y: m - r1, width: 2*r1, height: 2*r1))
|
||||
context.addEllipse(in: CGRect(x: m - r2, y: m - r2, width: 2*r2, height: 2*r2))
|
||||
let r3 = CGRect(x: c, y: c, width: sz - 2*c, height: sz - 2*c)
|
||||
context.addEllipse(in: r3)
|
||||
context.addRect(r3)
|
||||
|
||||
UIColor.clear.setFill()
|
||||
UIColor.gray.setStroke()
|
||||
let rounded = UIBezierPath(roundedRect: rect.insetBy(dx: lineWidth/2, dy: lineWidth/2), cornerRadius: corner)
|
||||
rounded.lineWidth = lineWidth
|
||||
rounded.stroke()
|
||||
|
||||
let img = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext()
|
||||
return img
|
||||
}()
|
||||
|
||||
private static let apple : UIImage? = {
|
||||
let rect = CGRect(x: 0, y: 0, width: 30, height: 30)
|
||||
UIGraphicsBeginImageContextWithOptions(rect.size, false, 0.0)
|
||||
|
||||
// #colorLiteral(red: 0.5843137503, green: 0.8235294223, blue: 0.4196078479, alpha: 1).setFill()
|
||||
// UIBezierPath(roundedRect: rect, cornerRadius: 0).fill()
|
||||
// print("drawing")
|
||||
let fs = 36 as CGFloat
|
||||
let hFont = UIFont.systemFont(ofSize: fs)
|
||||
var attrib = [
|
||||
NSAttributedString.Key.font: hFont,
|
||||
NSAttributedString.Key.foregroundColor: UIColor.gray
|
||||
]
|
||||
|
||||
let str = "" as NSString
|
||||
let actualHeight = str.size(withAttributes: attrib).height
|
||||
attrib[NSAttributedString.Key.font] = hFont.withSize(fs * fs / actualHeight)
|
||||
|
||||
let strW = str.size(withAttributes: attrib).width
|
||||
str.draw(at: CGPoint(x: (rect.size.width - strW) / 2.0, y: -3), withAttributes: attrib)
|
||||
|
||||
let img = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext()
|
||||
return img
|
||||
}()
|
||||
|
||||
private static let cacheDir = URL.documentDir().appendingPathComponent("app-store-search-cache", isDirectory:true)
|
||||
|
||||
private static func local(_ bundleId: String) -> URL {
|
||||
cacheDir.appendingPathComponent("\(bundleId).img")
|
||||
}
|
||||
|
||||
static func initCache() {
|
||||
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
|
||||
static func image(_ bundleId: String?, ifNotStored: (() -> Void)? = nil) -> UIImage? {
|
||||
guard let appId = bundleId else {
|
||||
return unknown
|
||||
}
|
||||
guard let data = try? Data(contentsOf: local(appId)),
|
||||
let img = UIImage(data: data, scale: 2.0) else {
|
||||
ifNotStored?()
|
||||
return appId.hasPrefix("com.apple.") ? apple : unknown
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
static func download(_ bundleId: String, urlStr: String, whenDone: @escaping () -> Void) {
|
||||
if let url = URL(string: urlStr) {
|
||||
url.download(to: local(bundleId), onSuccess: whenDone)
|
||||
}
|
||||
}
|
||||
}
|
||||
166
main/Recordings/TVCAppSearch.swift
Normal file
166
main/Recordings/TVCAppSearch.swift
Normal file
@@ -0,0 +1,166 @@
|
||||
import UIKit
|
||||
|
||||
protocol TVCAppSearchDelegate {
|
||||
func appSearch(didSelect bundleId: String, appName: String?, developer: String?)
|
||||
}
|
||||
|
||||
class TVCAppSearch: UITableViewController, UISearchBarDelegate {
|
||||
|
||||
private var dataSource: [AppStoreSearch.Result] = []
|
||||
private var dataSourceLocal: [AppBundleInfo] = []
|
||||
private var isLoading: Bool = false
|
||||
private var searchActive: Bool = false
|
||||
var delegate: TVCAppSearchDelegate?
|
||||
|
||||
@IBOutlet private var searchBar: UISearchBar!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
BundleIcon.initCache()
|
||||
dataSourceLocal = AppDB?.appBundleList() ?? []
|
||||
}
|
||||
|
||||
override var keyCommands: [UIKeyCommand]? {
|
||||
[UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(closeThis))]
|
||||
}
|
||||
|
||||
@objc private func closeThis() {
|
||||
searchBar.endEditing(true)
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
// MARK: - Table View Data Source
|
||||
|
||||
override func numberOfSections(in _: UITableView) -> Int {
|
||||
dataSourceLocal.count > 0 ? 2 : 1
|
||||
}
|
||||
|
||||
override func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
switch section {
|
||||
case 0: return max(1, dataSource.count) + (searchActive ? 1 : 0)
|
||||
case 1: return dataSourceLocal.count
|
||||
default: preconditionFailure()
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
switch section {
|
||||
case 0: return "AppStore"
|
||||
case 1: return "Found in other recordings"
|
||||
default: preconditionFailure()
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "AppStoreSearchCell")!
|
||||
let bundleId: String
|
||||
let altLoadUrl: String?
|
||||
|
||||
switch indexPath.section {
|
||||
case 0:
|
||||
guard dataSource.count > 0, indexPath.row < dataSource.count else {
|
||||
if indexPath.row == 0 {
|
||||
cell.textLabel?.text = isLoading ? "Loading …" : "no results"
|
||||
cell.isUserInteractionEnabled = false
|
||||
} else {
|
||||
cell.textLabel?.text = "Create manually …"
|
||||
}
|
||||
cell.detailTextLabel?.text = nil
|
||||
cell.imageView?.image = nil
|
||||
return cell
|
||||
}
|
||||
let src = dataSource[indexPath.row]
|
||||
bundleId = src.bundleId
|
||||
altLoadUrl = src.imageURL
|
||||
cell.textLabel?.text = src.name
|
||||
cell.detailTextLabel?.text = src.developer
|
||||
case 1:
|
||||
let src = dataSourceLocal[indexPath.row]
|
||||
bundleId = src.bundleId
|
||||
altLoadUrl = nil
|
||||
cell.textLabel?.text = src.name
|
||||
cell.detailTextLabel?.text = src.author
|
||||
default:
|
||||
preconditionFailure()
|
||||
}
|
||||
|
||||
cell.imageView?.image = BundleIcon.image(bundleId) {
|
||||
guard let url = altLoadUrl else { return }
|
||||
BundleIcon.download(bundleId, urlStr: url) {
|
||||
DispatchQueue.main.async {
|
||||
tableView.reloadRows(at: [indexPath], with: .automatic)
|
||||
}
|
||||
}
|
||||
}
|
||||
cell.isUserInteractionEnabled = true
|
||||
cell.imageView?.layer.cornerRadius = 6.75
|
||||
cell.imageView?.layer.masksToBounds = true
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
switch indexPath.section {
|
||||
case 0:
|
||||
guard indexPath.row < dataSource.count else {
|
||||
let alert = AskAlert(title: "App Name",
|
||||
text: "Be as descriptive as possible. Preferably use app bundle id if available. Alternatively use app name or a link to a public repository.",
|
||||
buttonText: "Set") {
|
||||
self.delegate?.appSearch(didSelect: "un.known", appName: $0.textFields?.first?.text, developer: nil)
|
||||
self.closeThis()
|
||||
}
|
||||
alert.addTextField { $0.placeholder = "com.apple.notes" }
|
||||
alert.presentIn(self)
|
||||
return
|
||||
}
|
||||
let src = dataSource[indexPath.row]
|
||||
delegate?.appSearch(didSelect: src.bundleId, appName: src.name, developer: src.developer)
|
||||
case 1:
|
||||
let src = dataSourceLocal[indexPath.row]
|
||||
delegate?.appSearch(didSelect: src.bundleId, appName: src.name, developer: src.author)
|
||||
default: preconditionFailure()
|
||||
}
|
||||
closeThis()
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Search Bar Delegate
|
||||
|
||||
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
||||
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(performSearch), object: nil)
|
||||
isLoading = true
|
||||
tableView.reloadData()
|
||||
if searchText.count > 0 {
|
||||
perform(#selector(performSearch), with: nil, afterDelay: 0.4)
|
||||
} else {
|
||||
performSearch()
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal callback function for delayed text evaluation.
|
||||
/// This way we can avoid unnecessary searches while user is typing.
|
||||
@objc private func performSearch() {
|
||||
isLoading = false
|
||||
let term = searchBar.text?.lowercased() ?? ""
|
||||
searchActive = term.count > 0
|
||||
guard searchActive else {
|
||||
dataSource = []
|
||||
tableView.reloadData()
|
||||
return
|
||||
}
|
||||
AppStoreSearch.search(term) {
|
||||
self.dataSource = $0 ?? []
|
||||
DispatchQueue.main.async {
|
||||
self.tableView.reloadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
|
||||
searchBar.endEditing(true)
|
||||
}
|
||||
|
||||
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
|
||||
closeThis()
|
||||
}
|
||||
}
|
||||
@@ -64,12 +64,22 @@ class TVCPreviousRecords: UITableViewController, EditActionsRemove {
|
||||
dataSource.count
|
||||
}
|
||||
|
||||
// override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
// let lbl = QuickUI.label("Previous Recordings", align: .center)
|
||||
// lbl.font = lbl.font.bold()
|
||||
// lbl.backgroundColor = .sysBackground
|
||||
// return lbl
|
||||
// }
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "PreviousRecordCell")!
|
||||
let x = dataSource[indexPath.row]
|
||||
cell.textLabel?.text = x.title ?? x.fallbackTitle
|
||||
cell.textLabel?.textColor = (x.title == nil) ? .systemGray : nil
|
||||
cell.detailTextLabel?.text = "at \(DateFormat.seconds(x.start)), duration: \(TimeFormat.from(x.duration ?? 0))"
|
||||
cell.detailTextLabel?.text = "at \(DateFormat.minutes(x.start)), duration: \(TimeFormat.from(x.duration ?? 0))"
|
||||
cell.imageView?.image = x.isLongTerm ? nil : BundleIcon.image(x.appId)
|
||||
cell.imageView?.layer.cornerRadius = 6.75
|
||||
cell.imageView?.layer.masksToBounds = true
|
||||
return cell
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import UIKit
|
||||
|
||||
class TVCRecordingDetails: UITableViewController, EditActionsRemove {
|
||||
var record: Recording!
|
||||
private lazy var isLongRecording: Bool = (record.duration ?? 0) > Timestamp.hours(1)
|
||||
private lazy var isLongRecording: Bool = record.isLongTerm
|
||||
|
||||
private var showRaw: Bool = false
|
||||
/// Sorted by `ts` in ascending order (oldest first)
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
import UIKit
|
||||
|
||||
class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate {
|
||||
class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate, TVCAppSearchDelegate {
|
||||
|
||||
var record: Recording!
|
||||
var deleteOnCancel: Bool = false
|
||||
var appId: String?
|
||||
|
||||
@IBOutlet private var buttonCancel: UIBarButtonItem!
|
||||
@IBOutlet private var buttonSave: UIBarButtonItem!
|
||||
@IBOutlet private var inputTitle: UITextField!
|
||||
@IBOutlet private var appTitle: UILabel!
|
||||
@IBOutlet private var appDeveloper: UILabel!
|
||||
@IBOutlet private var appIcon: UIImageView!
|
||||
@IBOutlet private var inputNotes: UITextView!
|
||||
@IBOutlet private var inputDetails: UITextView!
|
||||
@IBOutlet private var noteBottom: NSLayoutConstraint!
|
||||
|
||||
@IBOutlet private var chooseAppTap: UITapGestureRecognizer!
|
||||
|
||||
override func viewDidLoad() {
|
||||
inputTitle.placeholder = record.fallbackTitle
|
||||
inputTitle.text = record.title
|
||||
if record.isLongTerm {
|
||||
appId = nil
|
||||
appIcon.image = nil
|
||||
appTitle.text = "Background Recording"
|
||||
appDeveloper.text = nil
|
||||
chooseAppTap.isEnabled = false
|
||||
} else {
|
||||
appId = record.appId
|
||||
appIcon.image = BundleIcon.image(record.appId)
|
||||
appIcon.layer.cornerRadius = 6.75
|
||||
appIcon.layer.masksToBounds = true
|
||||
if record.appId == nil {
|
||||
appTitle.text = "Tap here to choose app"
|
||||
appDeveloper.text = record.title
|
||||
} else {
|
||||
appTitle.text = record.title ?? record.fallbackTitle
|
||||
appDeveloper.text = record.subtitle
|
||||
}
|
||||
}
|
||||
inputNotes.text = record.notes
|
||||
inputDetails.text = """
|
||||
Start: \(DateFormat.seconds(record.start))
|
||||
@@ -31,6 +54,11 @@ class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate
|
||||
UIResponder.keyboardWillHideNotification.observe(call: #selector(keyboardWillHide), on: self)
|
||||
}
|
||||
|
||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
||||
if let tvc = segue.destination as? TVCAppSearch {
|
||||
tvc.delegate = self
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Save & Cancel Buttons
|
||||
|
||||
@@ -41,7 +69,15 @@ class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate
|
||||
deleteOnCancel = false
|
||||
}
|
||||
QLog.Debug("updating record #\(record.id)")
|
||||
record.title = (inputTitle.text == "") ? nil : inputTitle.text
|
||||
if let id = appId, id != "" {
|
||||
record.appId = id
|
||||
record.title = (appTitle.text == "") ? nil : appTitle.text
|
||||
record.subtitle = (appDeveloper.text == "") ? nil : appDeveloper.text
|
||||
} else {
|
||||
record.appId = nil
|
||||
record.title = nil
|
||||
record.subtitle = nil
|
||||
}
|
||||
record.notes = (inputNotes.text == "") ? nil : inputNotes.text
|
||||
dismiss(animated: true) {
|
||||
RecordingsDB.update(self.record)
|
||||
@@ -121,11 +157,18 @@ class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate
|
||||
func textViewDidChange(_ _: UITextView) { validateSaveButton() }
|
||||
|
||||
private func validateSaveButton() {
|
||||
let changed = (inputTitle.text != record.title ?? "" || inputNotes.text != record.notes ?? "")
|
||||
let changed = (appId != record.appId
|
||||
|| (appTitle.text != record.title && appTitle.text != "Tap here to choose app")
|
||||
|| appDeveloper.text != record.subtitle
|
||||
|| inputNotes.text != record.notes ?? "")
|
||||
buttonSave.isEnabled = changed || deleteOnCancel // always allow save for new recordings
|
||||
}
|
||||
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
textField == inputTitle ? inputNotes.becomeFirstResponder() : true
|
||||
func appSearch(didSelect bundleId: String, appName: String?, developer: String?) {
|
||||
appId = bundleId
|
||||
appTitle.text = appName
|
||||
appDeveloper.text = developer
|
||||
appIcon.image = BundleIcon.image(bundleId)
|
||||
validateSaveButton()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ class VCRecordings: UIViewController, UINavigationControllerDelegate {
|
||||
super.viewWillAppear(animated)
|
||||
if currentRecording != nil { startTimer(animate: false) }
|
||||
navigationController?.setNavigationBarHidden(true, animated: animated)
|
||||
// set hidden in will appear causes UITableViewAlertForLayoutOutsideViewHierarchy
|
||||
// but otherwise navBar is visible during transition
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
@@ -120,7 +122,7 @@ class VCRecordings: UIViewController, UINavigationControllerDelegate {
|
||||
"Use the App as you would normally. Try to get to all corners and functionality the App provides. " +
|
||||
"When you feel that you have captured enough content, come back to ").italic("AppCheck").normal(" and stop the recording." +
|
||||
"\n\n" +
|
||||
"Upon completion you will find your recording in the 'Previous Recordings' section. " +
|
||||
"Upon completion you will find your recording in the section below. " +
|
||||
"You can review your results and remove user specific information if necessary.")
|
||||
))
|
||||
x.addSheet().addArrangedSubview(QuickUI.text(attributed: NSMutableAttributedString()
|
||||
|
||||
Reference in New Issue
Block a user