Bugfixes
- Disable cell animations for huge changes - Updating a cell keeps the old position whenever possible - Async `didChangeDateFilter` - Fixes bug where saving a recording would persist entries again - Small changes to `TimeFormat`, `AlertDeleteLogs` and `binTreeIndex()`
This commit is contained in:
@@ -17,6 +17,8 @@ class FilterPipeline<T> {
|
|||||||
private var display: PipelineSorting<T>!
|
private var display: PipelineSorting<T>!
|
||||||
private(set) weak var delegate: FilterPipelineDelegate?
|
private(set) weak var delegate: FilterPipelineDelegate?
|
||||||
|
|
||||||
|
private var cellAnimations: Bool = true
|
||||||
|
|
||||||
required init(withDelegate: FilterPipelineDelegate) {
|
required init(withDelegate: FilterPipelineDelegate) {
|
||||||
delegate = withDelegate
|
delegate = withDelegate
|
||||||
}
|
}
|
||||||
@@ -39,6 +41,8 @@ class FilterPipeline<T> {
|
|||||||
/// - Returns: Index in `dataSource` and found object or `nil` if no matching item found.
|
/// - Returns: Index in `dataSource` and found object or `nil` if no matching item found.
|
||||||
/// - Complexity: O(*n*), where *n* is the length of the `dataSource`.
|
/// - Complexity: O(*n*), where *n* is the length of the `dataSource`.
|
||||||
func dataSourceGet(where predicate: ((T) -> Bool)) -> (index: Int, object: T)? {
|
func dataSourceGet(where predicate: ((T) -> Bool)) -> (index: Int, object: T)? {
|
||||||
|
// TODO: use sorted dataSource for binary lookup?
|
||||||
|
// would require to shift filter and sorting indices for every new element
|
||||||
guard let i = dataSource.firstIndex(where: predicate) else {
|
guard let i = dataSource.firstIndex(where: predicate) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -166,6 +170,20 @@ class FilterPipeline<T> {
|
|||||||
|
|
||||||
// MARK: data updates
|
// MARK: data updates
|
||||||
|
|
||||||
|
/// Disable individual cell updates (update, move, insert & remove actions)
|
||||||
|
func pauseCellAnimations(if condition: Bool) {
|
||||||
|
cellAnimations = delegate?.tableView.isFrontmost ?? false && !condition
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allow individual cell updates (update, move, insert & remove actions) if tableView `isFrontmost`
|
||||||
|
/// - Parameter reloadTable: If `true` and cell animations are disabled, perform `tableView.reloadData()`
|
||||||
|
func continueCellAnimations(reloadTable: Bool = false) {
|
||||||
|
if !cellAnimations {
|
||||||
|
cellAnimations = true
|
||||||
|
if reloadTable { delegate?.tableView.reloadData() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Add new element to the original `dataSource` and immediately apply filter and sorting.
|
/// Add new element to the original `dataSource` and immediately apply filter and sorting.
|
||||||
/// - Complexity: O((*m*+1) log *n*), where *m* is the number of filters and *n* the number of elements in each filter.
|
/// - Complexity: O((*m*+1) log *n*), where *m* is the number of filters and *n* the number of elements in each filter.
|
||||||
func addNew(_ obj: T) {
|
func addNew(_ obj: T) {
|
||||||
@@ -176,7 +194,7 @@ class FilterPipeline<T> {
|
|||||||
}
|
}
|
||||||
// survived all filters
|
// survived all filters
|
||||||
let displayIndex = display.insertNew(index)
|
let displayIndex = display.insertNew(index)
|
||||||
delegate?.tableView.safeInsertRow(displayIndex, with: .left)
|
if cellAnimations { delegate?.tableView.safeInsertRow(displayIndex, with: .left) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update element at `index` in the original `dataSource` and immediately re-apply filter and sorting.
|
/// Update element at `index` in the original `dataSource` and immediately re-apply filter and sorting.
|
||||||
@@ -190,21 +208,22 @@ class FilterPipeline<T> {
|
|||||||
let oldPos = display.deleteOld(index)
|
let oldPos = display.deleteOld(index)
|
||||||
dataSource[index] = obj
|
dataSource[index] = obj
|
||||||
guard status.display else {
|
guard status.display else {
|
||||||
if let old = oldPos { delegate?.tableView.safeDeleteRows([old]) }
|
if cellAnimations, oldPos != -1 { delegate?.tableView.safeDeleteRows([oldPos]) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let newPos = display.insertNew(index)
|
let newPos = display.insertNew(index, previousIndex: oldPos)
|
||||||
if let old = oldPos {
|
guard cellAnimations else { return }
|
||||||
if old == newPos {
|
if oldPos == -1 {
|
||||||
delegate?.tableView.safeReloadRow(old)
|
delegate?.tableView.safeInsertRow(newPos, with: .left)
|
||||||
|
} else {
|
||||||
|
if oldPos == newPos {
|
||||||
|
delegate?.tableView.safeReloadRow(oldPos)
|
||||||
} else {
|
} else {
|
||||||
delegate?.tableView.safeMoveRow(old, to: newPos)
|
delegate?.tableView.safeMoveRow(oldPos, to: newPos)
|
||||||
if delegate?.tableView.isFrontmost ?? false {
|
if delegate?.tableView.isFrontmost ?? false {
|
||||||
delegate?.rowNeedsUpdate(newPos)
|
delegate?.rowNeedsUpdate(newPos)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
delegate?.tableView.safeInsertRow(newPos, with: .left)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +240,7 @@ class FilterPipeline<T> {
|
|||||||
filter.shiftRemove(indices: sorted)
|
filter.shiftRemove(indices: sorted)
|
||||||
}
|
}
|
||||||
let indices = display.shiftRemove(indices: sorted)
|
let indices = display.shiftRemove(indices: sorted)
|
||||||
delegate?.tableView.safeDeleteRows(indices)
|
if cellAnimations { delegate?.tableView.safeDeleteRows(indices) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,20 +387,29 @@ class PipelineSorting<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Add new element and automatically sort according to predicate
|
/// Add new element and automatically sort according to predicate
|
||||||
/// - Parameter index: Index of the element position in the original `dataSource`
|
/// - Parameters:
|
||||||
|
/// - index: Index of the element position in the original `dataSource`
|
||||||
|
/// - prev: If greater than `0`, try re-insert at the same position.
|
||||||
/// - Returns: Index in the projection
|
/// - Returns: Index in the projection
|
||||||
/// - Complexity: O(log *n*), where *n* is the length of the `projection`.
|
/// - Complexity: O(log *n*), where *n* is the length of the `projection`.
|
||||||
@discardableResult fileprivate func insertNew(_ index: Int) -> Int {
|
@discardableResult fileprivate func insertNew(_ index: Int, previousIndex prev: Int = -1) -> Int {
|
||||||
projection.binTreeInsert(index, compare: comperator)
|
if prev >= 0, prev < projection.count {
|
||||||
|
if (prev == 0 || !comperator(index, projection[prev - 1])), !comperator(projection[prev], index) {
|
||||||
|
// If element can be inserted at the same position without resorting, do that
|
||||||
|
projection.insert(index, at: prev)
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return projection.binTreeInsert(index, compare: comperator)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove element from projection
|
/// Remove element from projection
|
||||||
/// - Parameter index: Index of the element position in the original `dataSource`
|
/// - Parameter index: Index of the element position in the original `dataSource`
|
||||||
/// - Returns: Index in the projection or `nil` if element did not exist
|
/// - Returns: Index in the projection or `-1` if element did not exist
|
||||||
/// - Complexity: O(*n*), where *n* is the length of the `projection`.
|
/// - Complexity: O(*n*), where *n* is the length of the `projection`.
|
||||||
fileprivate func deleteOld(_ index: Int) -> Int? {
|
fileprivate func deleteOld(_ index: Int) -> Int {
|
||||||
guard let i = projection.firstIndex(of: index) else {
|
guard let i = projection.firstIndex(of: index) else {
|
||||||
return nil
|
return -1
|
||||||
}
|
}
|
||||||
projection.remove(at: i)
|
projection.remove(at: i)
|
||||||
return i
|
return i
|
||||||
|
|||||||
@@ -52,10 +52,10 @@ class GroupedDomainDataSource {
|
|||||||
sync.pause()
|
sync.pause()
|
||||||
if let affectedDomain = notification?.object as? String {
|
if let affectedDomain = notification?.object as? String {
|
||||||
partiallyReloadFromSource(affectedDomain)
|
partiallyReloadFromSource(affectedDomain)
|
||||||
sync.start()
|
sync.continue()
|
||||||
} else {
|
} else {
|
||||||
pipeline.reload(fromSource: true, whenDone: {
|
pipeline.reload(fromSource: true, whenDone: {
|
||||||
sync.start()
|
sync.continue()
|
||||||
refreshControl?.endRefreshing()
|
refreshControl?.endRefreshing()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -86,11 +86,14 @@ class GroupedDomainDataSource {
|
|||||||
|
|
||||||
/// Callback fired when background sync added new entries to the list. (`NotifySyncInsert` notification)
|
/// Callback fired when background sync added new entries to the list. (`NotifySyncInsert` notification)
|
||||||
@objc private func syncInsert(_ notification: Notification) {
|
@objc private func syncInsert(_ notification: Notification) {
|
||||||
|
sync.pause()
|
||||||
|
defer { sync.continue() }
|
||||||
let range = notification.object as! SQLiteRowRange
|
let range = notification.object as! SQLiteRowRange
|
||||||
guard let latest = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent) else {
|
guard let latest = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent) else {
|
||||||
assertionFailure("NotifySyncInsert fired with empty range")
|
assertionFailure("NotifySyncInsert fired with empty range")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
pipeline.pauseCellAnimations(if: latest.count > 14)
|
||||||
for x in latest {
|
for x in latest {
|
||||||
if let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) {
|
if let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) {
|
||||||
pipeline.update(obj + x, at: i)
|
pipeline.update(obj + x, at: i)
|
||||||
@@ -101,16 +104,19 @@ class GroupedDomainDataSource {
|
|||||||
}
|
}
|
||||||
tsLatest = max(tsLatest, x.lastModified)
|
tsLatest = max(tsLatest, x.lastModified)
|
||||||
}
|
}
|
||||||
|
pipeline.continueCellAnimations(reloadTable: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Callback fired when background sync removed old entries from the list. (`NotifySyncRemove` notification)
|
/// Callback fired when background sync removed old entries from the list. (`NotifySyncRemove` notification)
|
||||||
@objc private func syncRemove(_ notification: Notification) {
|
@objc private func syncRemove(_ notification: Notification) {
|
||||||
|
sync.pause()
|
||||||
|
defer { sync.continue() }
|
||||||
let range = notification.object as! SQLiteRowRange
|
let range = notification.object as! SQLiteRowRange
|
||||||
guard let outdated = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent),
|
guard let outdated = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent),
|
||||||
outdated.count > 0 else {
|
outdated.count > 0 else {
|
||||||
assertionFailure("NotifySyncRemove fired with empty range")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
pipeline.pauseCellAnimations(if: outdated.count > 14)
|
||||||
var listOfDeletes: [Int] = []
|
var listOfDeletes: [Int] = []
|
||||||
for x in outdated {
|
for x in outdated {
|
||||||
guard let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) else {
|
guard let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) else {
|
||||||
@@ -124,6 +130,7 @@ class GroupedDomainDataSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pipeline.remove(indices: listOfDeletes.sorted())
|
pipeline.remove(indices: listOfDeletes.sorted())
|
||||||
|
pipeline.continueCellAnimations(reloadTable: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,11 +151,12 @@ extension GroupedDomainDataSource {
|
|||||||
return // nothing has changed
|
return // nothing has changed
|
||||||
}
|
}
|
||||||
db.vacuum()
|
db.vacuum()
|
||||||
NotifyLogHistoryReset.postAsyncMain(domain) // calls deleteReloadFromSource(:)
|
NotifyLogHistoryReset.postAsyncMain(domain) // calls partiallyReloadFromSource(:)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reload a single data source entry. Callback fired by `reloadFromSource()`
|
/// Reload a single data source entry. Callback fired by `reloadFromSource()`
|
||||||
|
/// Only useful if `affectedFQDN` currently exists in `dataSource`. Can either update or remove entry.
|
||||||
private func partiallyReloadFromSource(_ affectedFQDN: String) {
|
private func partiallyReloadFromSource(_ affectedFQDN: String) {
|
||||||
let affectedParent = affectedFQDN.extractDomain()
|
let affectedParent = affectedFQDN.extractDomain()
|
||||||
guard parent == nil || parent == affectedParent else {
|
guard parent == nil || parent == affectedParent else {
|
||||||
@@ -159,20 +167,14 @@ extension GroupedDomainDataSource {
|
|||||||
// can only happen if delete sheet is open while background sync removed the element
|
// can only happen if delete sheet is open while background sync removed the element
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var removeOld = true
|
if var updated = AppDB?.dnsLogsGrouped(since: sync.tsEarliest, upto: tsLatest,
|
||||||
if let new = AppDB?.dnsLogsGrouped(since: sync.tsEarliest, upto: tsLatest, matchingDomain: affected, parentDomain: parent) {
|
matchingDomain: affected, parentDomain: parent)?.first {
|
||||||
assert(new.count < 2)
|
assert(old.object.domain == updated.domain)
|
||||||
for var x in new {
|
updated.options = DomainFilter[updated.domain]
|
||||||
x.options = DomainFilter[x.domain]
|
pipeline.update(updated, at: old.index)
|
||||||
if old.object.domain == x.domain {
|
} else {
|
||||||
pipeline.update(x, at: old.index)
|
pipeline.remove(indices: [old.index])
|
||||||
removeOld = false
|
|
||||||
} else {
|
|
||||||
pipeline.addNew(x)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if removeOld { pipeline.remove(indices: [old.index]) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ enum RecordingsDB {
|
|||||||
static func list() -> [Recording] { AppDB?.recordingGetAll() ?? [] }
|
static func list() -> [Recording] { AppDB?.recordingGetAll() ?? [] }
|
||||||
|
|
||||||
/// Copy log entries from generic `heap` table to recording specific `recLog` table
|
/// Copy log entries from generic `heap` table to recording specific `recLog` table
|
||||||
static func persist(_ r: Recording) { AppDB?.recordingLogsPersist(r) }
|
static func persist(_ r: Recording) {
|
||||||
|
sync.syncNow() // persist changes in cache before copying recording details
|
||||||
|
AppDB?.recordingLogsPersist(r)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get list of domains that occured during the recording
|
/// Get list of domains that occured during the recording
|
||||||
static func details(_ r: Recording) -> [RecordLog] {
|
static func details(_ r: Recording) -> [RecordLog] {
|
||||||
|
|||||||
@@ -11,34 +11,45 @@ class SyncUpdate {
|
|||||||
timer = Timer.repeating(interval, call: #selector(periodicUpdate), on: self)
|
timer = Timer.repeating(interval, call: #selector(periodicUpdate), on: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func periodicUpdate() { if paused == 0 { syncNow() } }
|
||||||
|
|
||||||
@objc private func didChangeDateFilter() {
|
@objc private func didChangeDateFilter() {
|
||||||
let lastXFilter = Pref.DateFilter.lastXMinTimestamp() ?? 0
|
let lastXFilter = Pref.DateFilter.lastXMinTimestamp() ?? 0
|
||||||
if tsEarliest < lastXFilter {
|
let before = tsEarliest
|
||||||
if let excess = AppDB?.dnsLogsRowRange(between: tsEarliest, and: lastXFilter) {
|
tsEarliest = lastXFilter
|
||||||
NotifySyncRemove.post(excess)
|
if before < lastXFilter {
|
||||||
|
DispatchQueue.global().async {
|
||||||
|
if let excess = AppDB?.dnsLogsRowRange(between: before, and: lastXFilter) {
|
||||||
|
NotifySyncRemove.postAsyncMain(excess)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if tsEarliest > lastXFilter {
|
} else if before > lastXFilter {
|
||||||
if let missing = AppDB?.dnsLogsRowRange(between: lastXFilter, and: tsEarliest) {
|
DispatchQueue.global().async {
|
||||||
NotifySyncInsert.post(missing)
|
if let missing = AppDB?.dnsLogsRowRange(between: lastXFilter, and: before) {
|
||||||
|
NotifySyncInsert.postAsyncMain(missing)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tsEarliest = lastXFilter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func start() { paused = 0 }
|
||||||
func pause() { paused += 1 }
|
func pause() { paused += 1 }
|
||||||
func start() { if paused > 0 { paused -= 1 } }
|
func `continue`() { if paused > 0 { paused -= 1 } }
|
||||||
|
|
||||||
@objc private func periodicUpdate() {
|
func syncNow() {
|
||||||
guard paused == 0, let db = AppDB else { return }
|
self.pause() // reduce concurrent load
|
||||||
if let inserted = db.dnsLogsPersist() { // move cache -> heap
|
|
||||||
|
if let inserted = AppDB?.dnsLogsPersist() { // move cache -> heap
|
||||||
NotifySyncInsert.post(inserted)
|
NotifySyncInsert.post(inserted)
|
||||||
}
|
}
|
||||||
if let lastXFilter = Pref.DateFilter.lastXMinTimestamp(), tsEarliest < lastXFilter {
|
if let lastXFilter = Pref.DateFilter.lastXMinTimestamp(), tsEarliest < lastXFilter {
|
||||||
if let removed = db.dnsLogsRowRange(between: tsEarliest, and: lastXFilter) {
|
if let removed = AppDB?.dnsLogsRowRange(between: tsEarliest, and: lastXFilter) {
|
||||||
NotifySyncRemove.post(removed)
|
NotifySyncRemove.post(removed)
|
||||||
}
|
}
|
||||||
tsEarliest = lastXFilter
|
tsEarliest = lastXFilter
|
||||||
}
|
}
|
||||||
// TODO: periodic hard delete old logs (will reset rowids!)
|
// TODO: periodic hard delete old logs (will reset rowids!)
|
||||||
|
|
||||||
|
self.continue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ class TestDataSource {
|
|||||||
QLog.Debug("SQLite path: \(URL.internalDB())")
|
QLog.Debug("SQLite path: \(URL.internalDB())")
|
||||||
|
|
||||||
let deleted = db.dnsLogsDelete("test.com", strict: false)
|
let deleted = db.dnsLogsDelete("test.com", strict: false)
|
||||||
QLog.Debug("Deleting \(deleted) rows matching 'test.com'")
|
try? db.run(sql: "DELETE FROM cache;")
|
||||||
|
QLog.Debug("Deleting \(deleted) rows matching 'test.com' (+ \(db.numberOfChanges) in cache)")
|
||||||
|
|
||||||
QLog.Debug("Writing 33 test logs")
|
QLog.Debug("Writing 33 test logs")
|
||||||
pStmt = try! db.logWritePrepare()
|
pStmt = try! db.logWritePrepare()
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func AskAlert(title: String?, text: String?, buttonText: String = "Continue", bu
|
|||||||
/// - buttons: Default: `[]`
|
/// - buttons: Default: `[]`
|
||||||
/// - lastIsDestructive: Default: `false`
|
/// - lastIsDestructive: Default: `false`
|
||||||
/// - cancelButtonText: Default: `"Dismiss"`
|
/// - cancelButtonText: Default: `"Dismiss"`
|
||||||
func BottomAlert(title: String?, text: String?, buttons: [String] = [], lastIsDestructive: Bool = false, cancelButtonText: String = "Dismiss", callback: @escaping (_ index: Int?) -> Void) -> UIAlertController {
|
func BottomAlert(title: String?, text: String?, buttons: [String] = [], lastIsDestructive: Bool = false, cancelButtonText: String = "Cancel", callback: @escaping (_ index: Int?) -> Void) -> UIAlertController {
|
||||||
let alert = UIAlertController(title: title, message: text, preferredStyle: .actionSheet)
|
let alert = UIAlertController(title: title, message: text, preferredStyle: .actionSheet)
|
||||||
for (i, btn) in buttons.enumerated() {
|
for (i, btn) in buttons.enumerated() {
|
||||||
let dangerous = (lastIsDestructive && i + 1 == buttons.count)
|
let dangerous = (lastIsDestructive && i + 1 == buttons.count)
|
||||||
@@ -54,21 +54,13 @@ func BottomAlert(title: String?, text: String?, buttons: [String] = [], lastIsDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func AlertDeleteLogs(_ domain: String, latest: Timestamp, success: @escaping (_ tsMin: Timestamp) -> Void) -> UIAlertController {
|
func AlertDeleteLogs(_ domain: String, latest: Timestamp, success: @escaping (_ tsMin: Timestamp) -> Void) -> UIAlertController {
|
||||||
let sinceNow = Timestamp.now() - latest
|
let minutesPassed = (Timestamp.now() - latest) / 60
|
||||||
var buttons = ["Last 5 minutes", "Last 15 minutes", "Last hour", "Last 24 hours", "Delete everything"]
|
let times: [Int] = [5, 15, 60, 1440].compactMap { minutesPassed < $0 ? $0 : nil }
|
||||||
var times: [Timestamp] = [300, 900, 3600, 86400]
|
let fmt = TimeFormat(.full, allowed: [.hour, .minute])
|
||||||
while times.count > 0, times[0] < sinceNow {
|
let labels = times.map { "Last " + (fmt.from(minutes: $0) ?? "?") }
|
||||||
buttons.removeFirst()
|
return BottomAlert(title: "Delete logs", text: "Delete logs for domain '\(domain)'", buttons: labels + ["Delete everything"], lastIsDestructive: true) {
|
||||||
times.removeFirst()
|
if let i = $0 {
|
||||||
}
|
success(i < times.count ? Timestamp.past(minutes: times[i]) : 0)
|
||||||
return BottomAlert(title: "Delete logs", text: "Delete logs for domain '\(domain)'", buttons: buttons, lastIsDestructive: true, cancelButtonText: "Cancel") {
|
|
||||||
guard let idx = $0 else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if idx >= times.count {
|
|
||||||
success(0)
|
|
||||||
} else {
|
|
||||||
success(Timestamp.now() - times[idx])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,13 @@ extension Array {
|
|||||||
|
|
||||||
/// Binary tree search operation.
|
/// Binary tree search operation.
|
||||||
/// - Warning: Array must be sorted already.
|
/// - Warning: Array must be sorted already.
|
||||||
/// - Parameter mustExist: Determine whether to return low index or `nil` if element is missing.
|
/// - Parameters:
|
||||||
|
/// - mustExist: Determine whether to return low index or `nil` if element is missing.
|
||||||
|
/// - first: If `true`, keep searching for first matching element.
|
||||||
/// - Returns: Index or `nil` (only if `mustExist = true` and element does not exist).
|
/// - Returns: Index or `nil` (only if `mustExist = true` and element does not exist).
|
||||||
/// - Complexity: O(log *n*), where *n* is the length of the array.
|
/// - Complexity: O(log *n*), where *n* is the length of the array.
|
||||||
func binTreeIndex(of element: Element, compare fn: CompareFn, mustExist: Bool = false) -> Int? {
|
func binTreeIndex(of element: Element, compare fn: CompareFn, mustExist: Bool = false, findFirst: Bool = false) -> Int? {
|
||||||
|
var found = false
|
||||||
var lo = 0, hi = self.count - 1
|
var lo = 0, hi = self.count - 1
|
||||||
while lo <= hi {
|
while lo <= hi {
|
||||||
let mid = (lo + hi)/2
|
let mid = (lo + hi)/2
|
||||||
@@ -31,10 +34,17 @@ extension Array {
|
|||||||
} else if fn(element, self[mid]) {
|
} else if fn(element, self[mid]) {
|
||||||
hi = mid - 1
|
hi = mid - 1
|
||||||
} else {
|
} else {
|
||||||
return mid
|
if !findFirst { return mid } // exit early if we dont care about first index
|
||||||
|
hi = mid - 1
|
||||||
|
found = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return mustExist ? nil : lo // not found, would be inserted at position lo
|
return (mustExist && !found) ? nil : lo // not found, would be inserted at position lo
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Binary tree lookup whether element exists. Performs `binTreeIndex(of:compare:mustExist:)` internally.
|
||||||
|
func binTreeExists(_ element: Element, compare fn: CompareFn) -> Bool {
|
||||||
|
binTreeIndex(of: element, compare: fn, mustExist: true) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Binary tree insert operation
|
/// Binary tree insert operation
|
||||||
|
|||||||
@@ -39,7 +39,27 @@ extension Timer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - TimeFormat
|
||||||
|
|
||||||
struct TimeFormat {
|
struct TimeFormat {
|
||||||
|
private var formatter: DateComponentsFormatter
|
||||||
|
|
||||||
|
/// Init new formatter with exactly 1 unit count. E.g., `61 min -> 1 hr`
|
||||||
|
/// - Parameter allowed: Default: `[.day, .hour, .minute, .second]`
|
||||||
|
init(_ style: DateComponentsFormatter.UnitsStyle, allowed: NSCalendar.Unit = [.day, .hour, .minute, .second]) {
|
||||||
|
formatter = DateComponentsFormatter()
|
||||||
|
formatter.maximumUnitCount = 1
|
||||||
|
formatter.allowedUnits = allowed
|
||||||
|
formatter.unitsStyle = style
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formatted duration string, e.g., `20 min` or `7 days`
|
||||||
|
func from(days: Int = 0, hours: Int = 0, minutes: Int = 0, seconds: Int = 0) -> String? {
|
||||||
|
formatter.string(from: DateComponents(day: days, hour: hours, minute: minutes, second: seconds))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: static
|
||||||
|
|
||||||
/// Time string with format `HH:mm`
|
/// Time string with format `HH:mm`
|
||||||
static func from(_ duration: Timestamp) -> String {
|
static func from(_ duration: Timestamp) -> String {
|
||||||
String(format: "%02d:%02d", duration / 60, duration % 60)
|
String(format: "%02d:%02d", duration / 60, duration % 60)
|
||||||
@@ -59,16 +79,4 @@ struct TimeFormat {
|
|||||||
static func since(_ date: Date, millis: Bool = false) -> String {
|
static func since(_ date: Date, millis: Bool = false) -> String {
|
||||||
from(Date().timeIntervalSince(date), millis: millis)
|
from(Date().timeIntervalSince(date), millis: millis)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Formatted duration string, e.g., `20 min` or `7 days`
|
|
||||||
/// - Parameters:
|
|
||||||
/// - minutes: Duration in minutes
|
|
||||||
/// - style: Default: `.short`
|
|
||||||
static func short(minutes: Int, style: DateComponentsFormatter.UnitsStyle = .short) -> String? {
|
|
||||||
let dcf = DateComponentsFormatter()
|
|
||||||
dcf.maximumUnitCount = 1
|
|
||||||
dcf.allowedUnits = [.day, .hour, .minute]
|
|
||||||
dcf.unitsStyle = style
|
|
||||||
return dcf.string(from: DateComponents(minute: minutes))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate
|
|||||||
// MARK: Save & Cancel Buttons
|
// MARK: Save & Cancel Buttons
|
||||||
|
|
||||||
@IBAction func didTapSave(_ sender: UIBarButtonItem) {
|
@IBAction func didTapSave(_ sender: UIBarButtonItem) {
|
||||||
if deleteOnCancel { // aka newly created
|
let newlyCreated = deleteOnCancel
|
||||||
|
if newlyCreated {
|
||||||
// if remains true, `viewDidDisappear` will delete the record
|
// if remains true, `viewDidDisappear` will delete the record
|
||||||
deleteOnCancel = false
|
deleteOnCancel = false
|
||||||
}
|
}
|
||||||
@@ -44,7 +45,9 @@ class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate
|
|||||||
record.notes = (inputNotes.text == "") ? nil : inputNotes.text
|
record.notes = (inputNotes.text == "") ? nil : inputNotes.text
|
||||||
dismiss(animated: true) {
|
dismiss(animated: true) {
|
||||||
RecordingsDB.update(self.record)
|
RecordingsDB.update(self.record)
|
||||||
RecordingsDB.persist(self.record)
|
if newlyCreated {
|
||||||
|
RecordingsDB.persist(self.record)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class VCRecordings: UIViewController, UINavigationControllerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func navigationController(_ nav: UINavigationController, didShow vc: UIViewController, animated: Bool) {
|
func navigationController(_ nav: UINavigationController, didShow vc: UIViewController, animated: Bool) {
|
||||||
|
// TODO: use interactive animation handler to dynamically animate "new recording" view
|
||||||
hideNewRecording(isRootVC: (vc == nav.viewControllers.first), didShow: true)
|
hideNewRecording(isRootVC: (vc == nav.viewControllers.first), didShow: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ class TVCDomains: UITableViewController, UISearchBarDelegate, FilterPipelineDele
|
|||||||
case .LastXMin: // most recent
|
case .LastXMin: // most recent
|
||||||
let lastXMin = Pref.DateFilter.LastXMin
|
let lastXMin = Pref.DateFilter.LastXMin
|
||||||
if lastXMin == 0 { fallthrough }
|
if lastXMin == 0 { fallthrough }
|
||||||
self.filterButtonDetail.title = TimeFormat.short(minutes: lastXMin, style: .abbreviated)
|
self.filterButtonDetail.title = TimeFormat(.abbreviated).from(minutes: lastXMin)
|
||||||
self.filterButton.image = UIImage(named: "filter-filled")
|
self.filterButton.image = UIImage(named: "filter-filled")
|
||||||
default:
|
default:
|
||||||
self.filterButtonDetail.title = ""
|
self.filterButtonDetail.title = ""
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class VCDateFilter: UIViewController, UIGestureRecognizerDelegate {
|
|||||||
sender.value = Float(i) / 9
|
sender.value = Float(i) / 9
|
||||||
if sender.tag != durationTimes[i] {
|
if sender.tag != durationTimes[i] {
|
||||||
sender.tag = durationTimes[i]
|
sender.tag = durationTimes[i]
|
||||||
durationLabel.text = (sender.tag == 0 ? "Off" : TimeFormat.short(minutes: sender.tag))
|
durationLabel.text = (sender.tag == 0 ? "Off" : TimeFormat(.short).from(minutes: sender.tag))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user