-
Notifications
You must be signed in to change notification settings - Fork 14
Implement dwell timers #465
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
715ec05
2d37272
e771c52
d6c726d
7fe48ad
1fba188
b24771b
6c0962f
65fee38
f99074c
931c3eb
72f2a2b
543fae4
6575fcc
44a47de
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,7 +12,7 @@ import KlaviyoSwift | |
|
|
||
| /// Represents a Klaviyo geofence | ||
| struct Geofence: Equatable, Hashable, Codable { | ||
| /// The geofence ID in the format "_k:{companyId}:{UUID}" with a "_k" prefix, company ID, and location ID from Klaviyo, separated by colons. | ||
| /// The geofence ID in the format "_k:{companyId}:{UUID}:{duration}" with a "_k" prefix, company ID, location ID, and optional duration from Klaviyo, separated by colons. | ||
| let id: String | ||
|
|
||
| /// Longitude of the geofence center | ||
|
|
@@ -26,21 +26,31 @@ struct Geofence: Equatable, Hashable, Codable { | |
|
|
||
| /// Company ID to which this geofence belongs, extracted from the geofence ID. | ||
| var companyId: String { | ||
| let components = id.split(separator: ":") | ||
| guard components.count == 3, components[0] == "_k" else { return "" } | ||
| let components = id.split(separator: ":", omittingEmptySubsequences: false) | ||
| guard components.count == 4, components[0] == "_k" else { return "" } | ||
belleklaviyo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return String(components[1]) | ||
| } | ||
|
|
||
| /// Location UUID to which this geofence belongs, extracted from the geofence ID. | ||
| var locationId: String { | ||
| let components = id.split(separator: ":", maxSplits: 2) | ||
| guard components.count == 3, components[0] == "_k" else { return "" } | ||
| let components = id.split(separator: ":", omittingEmptySubsequences: false) | ||
| guard components.count == 4, components[0] == "_k" else { return "" } | ||
| return String(components[2]) | ||
| } | ||
|
|
||
| /// Optional duration for this geofence to record a dwell event | ||
| var duration: Int? { | ||
| let components = id.split(separator: ":", omittingEmptySubsequences: false) | ||
| guard components.count == 4, components[0] == "_k" else { return nil } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. didn't we have an |
||
| let durationString = String(components[3]) | ||
| // Return nil if duration component is empty | ||
| guard !durationString.isEmpty else { return nil } | ||
| return Int(durationString) | ||
| } | ||
|
|
||
| /// Creates a new geofence | ||
| /// - Parameters: | ||
| /// - id: Unique identifier for the geofence in format "_k:{companyId}:{UUID}" where companyId is 6 alphanumeric characters | ||
| /// - id: Unique identifier for the geofence in format "_k:{companyId}:{UUID}:{duration}" where duration is optional | ||
| /// - longitude: Longitude coordinate of the geofence center | ||
| /// - latitude: Latitude coordinate of the geofence center | ||
| /// - radius: Radius of the geofence in meters | ||
|
|
@@ -82,12 +92,4 @@ extension CLCircularRegion { | |
| func toKlaviyoGeofence() throws -> Geofence { | ||
| try Geofence(id: identifier, longitude: center.longitude, latitude: center.latitude, radius: radius) | ||
| } | ||
|
|
||
| var klaviyoLocationId: String? { | ||
| do { | ||
| return try toKlaviyoGeofence().locationId | ||
| } catch { | ||
| return nil | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| // | ||
| // DwellTimerTracker.swift | ||
| // klaviyo-swift-sdk | ||
| // | ||
| // Created by Isobelle Lim on 1/27/25. | ||
| // | ||
|
|
||
| import Foundation | ||
| import KlaviyoCore | ||
| import KlaviyoSwift | ||
| import OSLog | ||
|
|
||
| /// Manages geofence dwell timer persistence and recovery. | ||
| /// | ||
| /// This tracker handles persistence of dwell timer data to UserDefaults, | ||
| /// allowing recovery of expired timers when the app terminates and relaunches. | ||
| /// The actual Timer objects are managed by KlaviyoLocationManager. | ||
| class DwellTimerTracker { | ||
| private static let dwellTimersKey = "klaviyo_dwell_timers" | ||
|
|
||
| private struct DwellTimerData: Codable { | ||
| let startTime: TimeInterval | ||
| let duration: Int | ||
| let companyId: String | ||
| } | ||
|
|
||
| /// Save dwell timer data to UserDefaults | ||
| /// | ||
| /// - Parameters: | ||
| /// - geofenceId: The geofence location ID | ||
| /// - startTime: The timestamp when the timer started | ||
| /// - duration: The duration of the timer in seconds | ||
| /// - companyId: The company ID associated with this geofence | ||
| func saveTimer(geofenceId: String, startTime: TimeInterval, duration: Int, companyId: String) { | ||
| var timerMap = loadTimers() | ||
| timerMap[geofenceId] = DwellTimerData(startTime: startTime, duration: duration, companyId: companyId) | ||
|
|
||
| guard let data = try? JSONEncoder().encode(timerMap) else { | ||
| return | ||
| } | ||
| UserDefaults.standard.set(data, forKey: Self.dwellTimersKey) | ||
| } | ||
|
|
||
| /// Remove dwell timer data from UserDefaults | ||
| /// | ||
| /// - Parameter geofenceId: The geofence location ID | ||
| func removeTimer(geofenceId: String) { | ||
| var timerMap = loadTimers() | ||
| timerMap.removeValue(forKey: geofenceId) | ||
|
|
||
| guard let data = try? JSONEncoder().encode(timerMap) else { | ||
| return | ||
| } | ||
| UserDefaults.standard.set(data, forKey: Self.dwellTimersKey) | ||
| } | ||
|
|
||
| /// Clear all persisted dwell timer data from UserDefaults | ||
| /// Called when geofence monitoring is stopped to prevent stale events | ||
| func clearAllTimers() { | ||
| UserDefaults.standard.removeObject(forKey: Self.dwellTimersKey) | ||
| } | ||
|
|
||
| /// Load all persisted dwell timers from UserDefaults | ||
| /// | ||
| /// - Returns: Dictionary mapping geofence IDs to their timer data | ||
| private func loadTimers() -> [String: DwellTimerData] { | ||
| guard let data = UserDefaults.standard.data(forKey: Self.dwellTimersKey), | ||
| let timerMap = try? JSONDecoder().decode([String: DwellTimerData].self, from: data) else { | ||
| return [:] | ||
| } | ||
| return timerMap | ||
| } | ||
|
|
||
| /// Check for expired timers, remove them from persistence, and return them | ||
| /// | ||
| /// - Returns: Array of expired timer information (geofence ID, duration, and company ID) | ||
| func getExpiredTimers() -> [(geofenceId: String, duration: Int, companyId: String)] { | ||
| let timerMap = loadTimers() | ||
| guard !timerMap.isEmpty else { return [] } | ||
|
|
||
| let currentTime = environment.date().timeIntervalSince1970 | ||
| var expiredTimers: [(geofenceId: String, duration: Int, companyId: String)] = [] | ||
|
|
||
| for (geofenceId, timerData) in timerMap { | ||
| // Check if timer expired (elapsed >= duration) | ||
| if currentTime - timerData.startTime >= TimeInterval(timerData.duration) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can you create a private helper function (or computed property) to abstract this logic and make it more readable? Something like: if timerData.isExpired {
...
} |
||
| expiredTimers.append((geofenceId: geofenceId, duration: timerData.duration, companyId: timerData.companyId)) | ||
| // Remove expired timer from persistence | ||
| removeTimer(geofenceId: geofenceId) | ||
|
|
||
| if #available(iOS 14.0, *) { | ||
| Logger.geoservices.info("🕐 Found expired dwell timer for region \(geofenceId) (expired while app was terminated)") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return expiredTimers | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.