diff --git a/AppCheck.xcodeproj/project.pbxproj b/AppCheck.xcodeproj/project.pbxproj index ff58aeb..186c4ab 100644 --- a/AppCheck.xcodeproj/project.pbxproj +++ b/AppCheck.xcodeproj/project.pbxproj @@ -16,6 +16,8 @@ 541AC5DD2399498A00A769D7 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 541AC5DB2399498A00A769D7 /* Main.storyboard */; }; 541AC5DF2399498B00A769D7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 541AC5DE2399498B00A769D7 /* Assets.xcassets */; }; 541AC5E22399498B00A769D7 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 541AC5E02399498B00A769D7 /* LaunchScreen.storyboard */; }; + 541FC47624A12D01009154D8 /* IBViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541FC47524A12D01009154D8 /* IBViews.swift */; }; + 541FC47824A1453F009154D8 /* VCCoOccurrence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541FC47724A1453F009154D8 /* VCCoOccurrence.swift */; }; 541FC9872497D81C00962623 /* TheGreatDestroyer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541FC9862497D81C00962623 /* TheGreatDestroyer.swift */; }; 542E2A982404973F001462DC /* TBCMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542E2A972404973F001462DC /* TBCMain.swift */; }; 542E2A9A24051556001462DC /* TVCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542E2A9924051556001462DC /* TVCSettings.swift */; }; @@ -176,6 +178,8 @@ 541AC5DE2399498B00A769D7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 541AC5E12399498B00A769D7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 541AC5E32399498B00A769D7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 541FC47524A12D01009154D8 /* IBViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IBViews.swift; sourceTree = ""; }; + 541FC47724A1453F009154D8 /* VCCoOccurrence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VCCoOccurrence.swift; sourceTree = ""; }; 541FC9862497D81C00962623 /* TheGreatDestroyer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TheGreatDestroyer.swift; sourceTree = ""; }; 542E2A972404973F001462DC /* TBCMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TBCMain.swift; sourceTree = ""; }; 542E2A9924051556001462DC /* TVCSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCSettings.swift; sourceTree = ""; }; @@ -328,6 +332,7 @@ 5412F8ED24571B8100A63D7A /* VCDateFilter.swift */, 54953E6023E0D69A0054345C /* TVCHosts.swift */, 54953E6E23E44CD00054345C /* TVCHostDetails.swift */, + 541FC47424A12CE9009154D8 /* Analytics */, ); path = Requests; sourceTree = ""; @@ -394,6 +399,14 @@ path = main; sourceTree = ""; }; + 541FC47424A12CE9009154D8 /* Analytics */ = { + isa = PBXGroup; + children = ( + 541FC47724A1453F009154D8 /* VCCoOccurrence.swift */, + ); + path = Analytics; + sourceTree = ""; + }; 542E2A9B24051F79001462DC /* media */ = { isa = PBXGroup; children = ( @@ -425,6 +438,7 @@ 54D8B979246C9F2000EB2414 /* FilterPipeline.swift */, 54448A3124899A4000771C96 /* SearchBarManager.swift */, 54EFA4E5248EEE240022D618 /* DatePickerAlert.swift */, + 541FC47524A12D01009154D8 /* IBViews.swift */, ); path = "Common Classes"; sourceTree = ""; @@ -830,6 +844,7 @@ 540E67842433FAFE00871BBE /* TVCPreviousRecords.swift in Sources */, 54B345A6241BB982004C53CC /* Notifications.swift in Sources */, 54448A2E2486464F00771C96 /* Array.swift in Sources */, + 541FC47824A1453F009154D8 /* VCCoOccurrence.swift in Sources */, 54B345AB241BBA5B004C53CC /* AlertSheet.swift in Sources */, 544C95262407B1C700AB89D0 /* SharedState.swift in Sources */, 545DDDCF243E6267003B6544 /* TutorialSheet.swift in Sources */, @@ -855,6 +870,7 @@ 5412F8EE24571B8200A63D7A /* VCDateFilter.swift in Sources */, 545DDDD124436983003B6544 /* QuickUI.swift in Sources */, 541AC5D82399498A00A769D7 /* AppDelegate.swift in Sources */, + 541FC47624A12D01009154D8 /* IBViews.swift in Sources */, 540E67822433483D00871BBE /* VCEditRecording.swift in Sources */, 54EFA4E82491A16A0022D618 /* Font.swift in Sources */, 54D8B97A246C9F2000EB2414 /* FilterPipeline.swift in Sources */, diff --git a/main/Assets.xcassets/.DS_Store b/main/Assets.xcassets/.DS_Store new file mode 100644 index 0000000..a60f8ea Binary files /dev/null and b/main/Assets.xcassets/.DS_Store differ diff --git a/main/Assets.xcassets/intersection.imageset/Contents.json b/main/Assets.xcassets/intersection.imageset/Contents.json new file mode 100644 index 0000000..d9287fd --- /dev/null +++ b/main/Assets.xcassets/intersection.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "img.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "img@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "img@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/main/Assets.xcassets/intersection.imageset/img.png b/main/Assets.xcassets/intersection.imageset/img.png new file mode 100644 index 0000000..4296b24 Binary files /dev/null and b/main/Assets.xcassets/intersection.imageset/img.png differ diff --git a/main/Assets.xcassets/intersection.imageset/img@2x.png b/main/Assets.xcassets/intersection.imageset/img@2x.png new file mode 100644 index 0000000..a24e900 Binary files /dev/null and b/main/Assets.xcassets/intersection.imageset/img@2x.png differ diff --git a/main/Assets.xcassets/intersection.imageset/img@3x.png b/main/Assets.xcassets/intersection.imageset/img@3x.png new file mode 100644 index 0000000..c5b09b8 Binary files /dev/null and b/main/Assets.xcassets/intersection.imageset/img@3x.png differ diff --git a/main/Base.lproj/Main.storyboard b/main/Base.lproj/Main.storyboard index ead653b..34cc95e 100644 --- a/main/Base.lproj/Main.storyboard +++ b/main/Base.lproj/Main.storyboard @@ -392,9 +392,20 @@ + + + + + + + + + + + - + @@ -424,11 +435,202 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1031,6 +1233,7 @@ Duration: 60:00 + diff --git a/main/Common Classes/IBViews.swift b/main/Common Classes/IBViews.swift new file mode 100644 index 0000000..0fe7789 --- /dev/null +++ b/main/Common Classes/IBViews.swift @@ -0,0 +1,83 @@ +import UIKit +import CoreGraphics + +// MARK: White Triangle Popup Arrow + +@IBDesignable +class PopupTriangle: UIView { + @IBInspectable var rotation: CGFloat = 0 + @IBInspectable var color: UIColor = .black + + override func draw(_ rect: CGRect) { + guard let c = UIGraphicsGetCurrentContext() else { return } + let w = rect.width, h = rect.height + switch rotation { + case 90: // right + c.lineFromTo(x1: 0, y1: 0, x2: w, y2: h/2) + c.addLine(to: CGPoint(x: 0, y: h)) + case 180: // bottom + c.lineFromTo(x1: w, y1: 0, x2: w/2, y2: h) + c.addLine(to: CGPoint(x: 0, y: 0)) + case 270: // left + c.lineFromTo(x1: w, y1: h, x2: 0, y2: h/2) + c.addLine(to: CGPoint(x: w, y: 0)) + default: // top + c.lineFromTo(x1: 0, y1: h, x2: w/2, y2: 0) + c.addLine(to: CGPoint(x: w, y: h)) + } + c.closePath() + c.setFillColor(color.cgColor) + c.fillPath() + } +} + + +// MARK: Label as Tag Bubble + +@IBDesignable +class TagLabel: UILabel { + private var em: CGFloat { font.pointSize } + @IBInspectable var padTop: CGFloat = 0 + @IBInspectable var padLeft: CGFloat = 0 + @IBInspectable var padRight: CGFloat = 0 + @IBInspectable var padBottom: CGFloat = 0 + private var padding: UIEdgeInsets { + .init(top: padTop + em/6, left: padLeft + em/3, + bottom: padBottom + em/6, right: padRight + em/3) + } + + override func textRect(forBounds bounds: CGRect, limitedToNumberOfLines numberOfLines: Int) -> CGRect { + let i = padding + let ii = UIEdgeInsets(top: -i.top, left: -i.left, bottom: -i.bottom, right: -i.right) + return super.textRect(forBounds: bounds.inset(by: i), + limitedToNumberOfLines: numberOfLines).inset(by: ii) + } + + override func drawText(in rect: CGRect) { + layer.masksToBounds = true + layer.cornerRadius = em/2.5 + super.drawText(in: rect.inset(by: padding)) + } +} + + +// MARK: Percentage meter + +@IBDesignable +class MeterBar: UIView { + @IBInspectable var percent: CGFloat = 0 { didSet { setNeedsDisplay() } } + @IBInspectable var barColor: UIColor = .sysFg + @IBInspectable var horizontal: Bool = false + + private var normPercent: CGFloat { 1 - max(0, min(percent, 1)) } + + override func draw(_ rect: CGRect) { + let c = UIGraphicsGetCurrentContext() + c?.setFillColor(barColor.cgColor) + if horizontal { + c?.fill(rect.insetBy(dx: normPercent * (rect.width/2), dy: 0)) + } else { + c?.fill(rect.insetBy(dx: 0, dy: normPercent * (rect.height/2))) + } + } +} diff --git a/main/DB/DBAppOnly.swift b/main/DB/DBAppOnly.swift index 85aea61..10b0286 100644 --- a/main/DB/DBAppOnly.swift +++ b/main/DB/DBAppOnly.swift @@ -172,15 +172,11 @@ extension SQLiteDatabase { /// Group DNS logs by domain, count occurences and number of blocked requests. /// - Parameters: /// - range: Whenever possible set range to improve SQL lookup times. `start <= rowid <= end ` - /// - ts1: Restrict result set `ts >= ?` - /// - ts2: Restrict result set `ts < ?` /// - matchingDomain: Restrict `(fqdn|domain) = ?`. Which column is used is determined by `parentDomain`. /// - parentDomain: If `nil` returns `domain` column. Else returns `fqdn` column with restriction on `domain == parentDomain`. /// - Returns: List of grouped domains with no particular sorting order. - func dnsLogsGrouped(range: SQLiteRowRange = (0,0), since ts1: Timestamp = 0, upto ts2: Timestamp = 0, - matchingDomain: String? = nil, parentDomain: String? = nil) -> [GroupedDomain]? - { - let Where = WhereClauseBuilder().and(in: range).and(min: ts1, max: ts2) + func dnsLogsGrouped(range: SQLiteRowRange, matchingDomain: String? = nil, parentDomain: String? = nil) -> [GroupedDomain]? { + let Where = WhereClauseBuilder().and(in: range) let col: String // fqdn or domain if let parent = parentDomain { // is subdomain col = "fqdn" @@ -206,7 +202,7 @@ extension SQLiteDatabase { /// - fqdn: Exact match for domain name `fqdn = ?` /// - range: Whenever possible set range to improve SQL lookup times. `start <= rowid <= end ` /// - Returns: List sorted by reverse timestamp order (newest first) - func timesForDomain(_ fqdn: String, range: SQLiteRowRange = (0,0)) -> [GroupedTsOccurrence]? { + func timesForDomain(_ fqdn: String, range: SQLiteRowRange) -> [GroupedTsOccurrence]? { let Where = WhereClauseBuilder().and(in: range).and("fqdn = ?", BindText(fqdn)) return try? run(sql: "SELECT ts, COUNT(ts), COUNT(opt) FROM heap \(Where) GROUP BY ts ORDER BY ts DESC;", bind: Where.bindings) { allRows($0) { @@ -218,6 +214,71 @@ extension SQLiteDatabase { +// MARK: - Context Analysis + +typealias ContextAnalysisResult = (domain: String, count: Int32, avg: Double, rank: Double) + +extension SQLiteDatabase { + /// Number of times how often given `fqdn` appears in the database + func dnsLogsCount(fqdn: String) -> Int? { + try? run(sql: "SELECT COUNT(*) FROM heap WHERE fqdn = ?;", bind: [BindText(fqdn)]) { + try ifStep($0, SQLITE_ROW) + return Int(sqlite3_column_int($0, 0)) + } + } + + /// Get sorted, unique list of `ts` with given `fqdn`. + func dnsLogsUniqTs(_ fqdn: String) -> [Timestamp]? { + try? run(sql: "SELECT DISTINCT ts FROM heap WHERE fqdn = ? ORDER BY ts;", bind: [BindText(fqdn)]) { + allRows($0) { sqlite3_column_int64($0, 0) } + } + } + + /// Find other domains occurring regularly at roughly the same time as `fqdn`. + /// - Warning: `times` list must be **sorted** by time in ascending order. + /// - Parameters: + /// - times: List of `ts` from `dnsLogsUniqTs(fqdn)` + /// - dt: Search for `ts - dt <= X <= ts + dt` + /// - fqdn: Rows matching this domain will be excluded from the result set. + /// - Returns: List of tuples ordered by rank (ASC). + func contextAnalysis(coOccurrence times: [Timestamp], plusMinus dt: Timestamp, exclude fqdn: String) -> [ContextAnalysisResult]? { + guard times.count > 0 else { return nil } + createFunction("fnDist") { + let x = $0.first as! Timestamp + let i = times.binTreeIndex(of: x, compare: <)! + let dist: Timestamp + switch i { + case 0: dist = times[0] - x + case times.count: dist = x - times[i-1] + default: dist = min(times[i] - x, x - times[i-1]) + } + return dist + } + // `avg ^ 2`: prefer results that are closer to `times` + // `_ / count`: prefer results with higher occurrence count + // `time / 2`: Weighting factor (low: prefer close, high: prefer count) + // `time` helpful esp. for smaller spans. `avg^2` will raise faster anyway. + let fnRank = "(avg * avg + (? / 2.0) + 1) / count" // +1 in case time == 0 -> avg^2 == 0 + // improve query by excluding entries that are: before the first, or after the last ts + let low = times.first! - dt + let high = times.last! + dt + return try? run(sql: """ + SELECT fqdn, count, avg, (\(fnRank)) rank FROM ( + SELECT fqdn, COUNT(*) count, AVG(dist) avg FROM ( + SELECT fqdn, fnDist(ts) dist FROM heap + WHERE ts BETWEEN ? AND ? AND fqdn != ? AND dist <= ? + ) GROUP BY fqdn + ) ORDER BY rank ASC LIMIT 99; + """, bind: [BindInt64(dt), BindInt64(low), BindInt64(high), BindText(fqdn), BindInt64(dt)]) { + allRows($0) { + (readText($0, 0) ?? "", sqlite3_column_int($0, 1), sqlite3_column_double($0, 2), sqlite3_column_double($0, 3)) + } + } + } +} + + + // MARK: - Recordings extension CreateTable { diff --git a/main/DB/DBCore.swift b/main/DB/DBCore.swift index 8332033..7bd77ee 100644 --- a/main/DB/DBCore.swift +++ b/main/DB/DBCore.swift @@ -138,6 +138,7 @@ extension SQLiteDatabase { if let r = result as? Blob { sqlite3_result_blob(context, r.bytes, Int32(r.bytes.count), nil) } else if let r = result as? Double { sqlite3_result_double(context, r) } else if let r = result as? Int64 { sqlite3_result_int64(context, r) } + else if let r = result as? Bool { sqlite3_result_int(context, r ? 1 : 0) } else if let r = result as? String { sqlite3_result_text(context, r, Int32(r.count), SQLITE_TRANSIENT) } else if result == nil { sqlite3_result_null(context) } else { fatalError("unsupported result type: \(String(describing: result))") } diff --git a/main/Extensions/SharedState.swift b/main/Extensions/SharedState.swift index 89d7bdc..81062ec 100644 --- a/main/Extensions/SharedState.swift +++ b/main/Extensions/SharedState.swift @@ -25,6 +25,12 @@ enum Pref { set { Pref.Bool(newValue, "didShowTutorialRecordings") } } } + enum ContextAnalyis { + static var CoOccurrenceTime: Int? { + get { Pref.Any("contextAnalyisCoOccurrenceTime") as? Int } + set { Pref.Any(newValue, "contextAnalyisCoOccurrenceTime") } + } + } enum DateFilter { static var Kind: DateFilterKind { get { DateFilterKind(rawValue: Pref.Int("dateFilterType"))! } diff --git a/main/Extensions/TableView.swift b/main/Extensions/TableView.swift index 710c873..bedfbc0 100644 --- a/main/Extensions/TableView.swift +++ b/main/Extensions/TableView.swift @@ -43,6 +43,14 @@ extension UITableView { func safeMoveRow(_ from: Int, to: Int) { isFrontmost ? moveRow(at: IndexPath(row: from), to: IndexPath(row: to)) : reloadData() } + + /// Recalculate and apply new `tableHeaderView` height. + func sizeHeaderToFit() { + if let head = tableHeaderView { + head.frame.size.height = head.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height + tableHeaderView = head + } + } } diff --git a/main/Requests/Analytics/VCCoOccurrence.swift b/main/Requests/Analytics/VCCoOccurrence.swift new file mode 100644 index 0000000..8997cc5 --- /dev/null +++ b/main/Requests/Analytics/VCCoOccurrence.swift @@ -0,0 +1,88 @@ +import UIKit + +class VCCoOccurrence: UIViewController, UITableViewDataSource { + var fqdn: String! + private var dataSource: [ContextAnalysisResult] = [] + + @IBOutlet private var tableView: UITableView! + @IBOutlet private var timeSegment: UISegmentedControl! + private let availableTimes = [0, 5, 15, 30] + private var selectedTime = -1 { + didSet { logTimeDelta = log(CGFloat(max(2, selectedTime+1))) } + } + private var logTimeDelta: CGFloat = 1 + private var logMaxCount: CGFloat = 1 + + override func viewDidLoad() { + super.viewDidLoad() + selectedTime = Pref.ContextAnalyis.CoOccurrenceTime ?? 5 // calls `didSet` and `logTimeDelta` + timeSegment.removeAllSegments() // clear IB values + for (i, time) in availableTimes.enumerated() { + timeSegment.insertSegment(withTitle: TimeFormat(.abbreviated).from(seconds: time), at: i, animated: false) + if time == selectedTime { + timeSegment.selectedSegmentIndex = i + } + } + reloadDataSource() + } + + func reloadDataSource() { + dataSource = [("Loading …", 0, 0, 0)] + logMaxCount = 1 + tableView.reloadData() + let domain = fqdn! + let time = Timestamp(selectedTime) + DispatchQueue.global().async { [weak self] in + guard let db = AppDB, let times = db.dnsLogsUniqTs(domain), times.count > 0 else { + return // should never happen, or what did you tap then? + } + guard let result = db.contextAnalysis(coOccurrence: times, plusMinus: time, exclude: domain) else { + return + } + self?.dataSource = result + self?.logMaxCount = log(CGFloat(result.reduce(0) { max($0, $1.count) })) + DispatchQueue.main.sync { [weak self] in + self?.tableView.reloadData() + } + } + } + + @IBAction func didChangeTime(_ sender: UISegmentedControl) { + selectedTime = availableTimes[sender.selectedSegmentIndex] + Pref.ContextAnalyis.CoOccurrenceTime = selectedTime + reloadDataSource() + } + + @IBAction func didClose(_ sender: UIBarButtonItem) { + dismiss(animated: true) + } + + + // MARK: - Table View Data Source + + func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { + dataSource.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "CoOccurrenceCell") as! CoOccurrenceCell + let src = dataSource[indexPath.row] + cell.title.text = src.domain + cell.rank.text = "\(indexPath.row + 1)." + cell.count.text = "\(src.count)" + cell.avgdiff.text = String(format: "%.2fs", src.avg) + + cell.countMeter.percent = (log(CGFloat(src.count)) / logMaxCount) + cell.avgdiffMeter.percent = 1 - (log(CGFloat(src.avg + 1)) / logTimeDelta) + return cell + } +} + +class CoOccurrenceCell: UITableViewCell { + @IBOutlet var title: UILabel! + @IBOutlet var rank: TagLabel! + @IBOutlet var count: TagLabel! + @IBOutlet var avgdiff: TagLabel! + @IBOutlet var countMeter: MeterBar! + @IBOutlet var avgdiffMeter: MeterBar! +} diff --git a/main/Requests/TVCHostDetails.swift b/main/Requests/TVCHostDetails.swift index f693c9f..6fccfa7 100644 --- a/main/Requests/TVCHostDetails.swift +++ b/main/Requests/TVCHostDetails.swift @@ -1,7 +1,9 @@ import UIKit -class TVCHostDetails: UITableViewController, SyncUpdateDelegate { +class TVCHostDetails: UITableViewController, SyncUpdateDelegate, UITabBarDelegate { + @IBOutlet private var actionsBar: UITabBar! + public var fullDomain: String! private var dataSource: [GroupedTsOccurrence] = [] // TODO: respect date reverse sort order @@ -12,7 +14,9 @@ class TVCHostDetails: UITableViewController, SyncUpdateDelegate { sync.addObserver(self) // calls `syncUpdate(reset:)` if #available(iOS 10.0, *) { sync.allowPullToRefresh(onTVC: self, forObserver: self) + actionsBar.unselectedItemTintColor = .systemBlue } + UIDevice.orientationDidChangeNotification.observe(call: #selector(didChangeOrientation), on: self) } // MARK: - Table View Data Source @@ -29,6 +33,30 @@ class TVCHostDetails: UITableViewController, SyncUpdateDelegate { } } +// ######################### +// # +// # MARK: - Tab Bar +// # +// ######################### + +extension TVCHostDetails { + + @objc private func didChangeOrientation(_ sender: Notification) { + tableView.sizeHeaderToFit() // otherwise TabBar won't compress + } + + func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { + tabBar.selectedItem = nil + performSegue(withIdentifier: "segueAnalysisCoOccurrence", sender: nil) + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "segueAnalysisCoOccurrence" { + (segue.destination as? VCCoOccurrence)?.fqdn = fullDomain + } + } +} + // ################################ // # // # MARK: - Partial Update diff --git a/main/Requests/VCDateFilter.swift b/main/Requests/VCDateFilter.swift index faac79b..d49ee47 100644 --- a/main/Requests/VCDateFilter.swift +++ b/main/Requests/VCDateFilter.swift @@ -118,34 +118,3 @@ class VCDateFilter: UIViewController, UIGestureRecognizerDelegate { } } } - - -// MARK: White Triangle Popup Arrow - -@IBDesignable -class PopupTriangle: UIView { - @IBInspectable var rotation: CGFloat = 0 - @IBInspectable var color: UIColor = .black - - override func draw(_ rect: CGRect) { - guard let c = UIGraphicsGetCurrentContext() else { return } - let w = rect.width, h = rect.height - switch rotation { - case 90: // right - c.lineFromTo(x1: 0, y1: 0, x2: w, y2: h/2) - c.addLine(to: CGPoint(x: 0, y: h)) - case 180: // bottom - c.lineFromTo(x1: w, y1: 0, x2: w/2, y2: h) - c.addLine(to: CGPoint(x: 0, y: 0)) - case 270: // left - c.lineFromTo(x1: w, y1: h, x2: 0, y2: h/2) - c.addLine(to: CGPoint(x: w, y: 0)) - default: // top - c.lineFromTo(x1: 0, y1: h, x2: w/2, y2: 0) - c.addLine(to: CGPoint(x: w, y: h)) - } - c.closePath() - c.setFillColor(color.cgColor) - c.fillPath() - } -}