diff --git a/.travis.yml b/.travis.yml index dff2ce6..b725302 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ language: objective-c -osx_image: xcode9.4 +osx_image: xcode10.1 script: - bundle exec fastlane test diff --git a/Gemfile.lock b/Gemfile.lock index d12d483..66bd206 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,7 +2,7 @@ GEM remote: https://rubygems.org/ specs: CFPropertyList (3.0.0) - activesupport (4.2.10) + activesupport (4.2.11) i18n (~> 0.7) minitest (~> 5.1) thread_safe (~> 0.3, >= 0.3.4) @@ -36,7 +36,7 @@ GEM fuzzy_match (~> 2.0.4) nap (~> 1.0) cocoapods-deintegrate (1.0.2) - cocoapods-downloader (1.2.1) + cocoapods-downloader (1.2.2) cocoapods-plugins (1.0.0) nap cocoapods-search (1.0.0) @@ -49,7 +49,7 @@ GEM colored2 (3.1.2) commander-fastlane (4.4.6) highline (~> 1.7.2) - concurrent-ruby (1.0.5) + concurrent-ruby (1.1.3) declarative (0.0.10) declarative-option (0.1.0) domain_name (0.5.20180417) @@ -58,15 +58,15 @@ GEM emoji_regex (0.1.1) escape (0.0.4) excon (0.62.0) - faraday (0.15.3) + faraday (0.15.4) multipart-post (>= 1.2, < 3) faraday-cookie_jar (0.0.6) faraday (>= 0.7.4) http-cookie (~> 1.0.0) faraday_middleware (0.12.2) faraday (>= 0.7.4, < 1.0) - fastimage (2.1.4) - fastlane (2.106.1) + fastimage (2.1.5) + fastlane (2.109.1) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.3, < 3.0.0) babosa (>= 1.0.2, < 2.0.0) @@ -114,10 +114,10 @@ GEM representable (~> 3.0) retriable (>= 2.0, < 4.0) signet (~> 0.9) - googleauth (0.6.6) + googleauth (0.6.7) faraday (~> 0.12) jwt (>= 1.4, < 3.0) - memoist (~> 0.12) + memoist (~> 0.16) multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (~> 0.7) @@ -127,7 +127,7 @@ GEM httpclient (2.8.3) i18n (0.9.5) concurrent-ruby (~> 1.0) - jazzy (0.9.3) + jazzy (0.9.4) cocoapods (~> 1.0) mustache (~> 0.99) open4 @@ -170,7 +170,7 @@ GEM rouge (2.0.7) ruby-macho (1.3.1) rubyzip (1.2.2) - sass (3.6.0) + sass (3.7.2) sass-listen (~> 4.0.0) sass-listen (4.0.0) rb-fsevent (~> 0.9, >= 0.9.4) @@ -192,8 +192,8 @@ GEM thread_safe (0.3.6) tty-cursor (0.6.0) tty-screen (0.6.5) - tty-spinner (0.8.0) - tty-cursor (>= 0.5.0) + tty-spinner (0.9.0) + tty-cursor (~> 0.6.0) tzinfo (1.2.5) thread_safe (~> 0.1) uber (0.1.0) @@ -204,7 +204,7 @@ GEM word_wrap (1.0.0) xcinvoke (0.3.0) liferaft (~> 0.0.6) - xcodeproj (1.6.0) + xcodeproj (1.7.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) diff --git a/GeoTrackKit/Core/GeoTrackManager.swift b/GeoTrackKit/Core/GeoTrackManager.swift index d6d43f9..7cb1832 100644 --- a/GeoTrackKit/Core/GeoTrackManager.swift +++ b/GeoTrackKit/Core/GeoTrackManager.swift @@ -19,22 +19,34 @@ public class GeoTrackManager: NSObject { public static let shared: GeoTrackService = GeoTrackManager() // GeoTrackService stuff - internal var trackingState: GeoTrackState = .notTracking + var trackingState: GeoTrackState = .notTracking /// Your app's name - internal var appName: String = "No Application Name" + var appName: String = "No Application Name" // Other stuff - internal var locationManager: CLLocationManager? + var locationManager: CLLocationManager? + /// The last Geo Point to be tracked fileprivate(set) public var lastPoint: CLLocation? + /// Are we authorized for location tracking? fileprivate(set) public var authorized: Bool = false + /// The Track fileprivate(set) public var track: GeoTrack? /// When we startup, if we find points to be older than this threshold, we toss them away. /// Defaults to 5 seconds, but you can adjust this as you see fit. - static var oldPointThreshold: TimeInterval = 5 + public static var oldPointThreshold: TimeInterval = 5 + + /// Sets the locationManager instance and then configures it to the needs + /// of GeoTrackKit. + /// + /// - Parameter locationManager: The locationManager instance to set. + public func setLocationManager(_ locationManager: CLLocationManager?) { + self.locationManager = locationManager + configureLocationManager() + } } // MARK: - API @@ -61,6 +73,16 @@ extension GeoTrackManager: GeoTrackService { return trackingState == .awaitingFix } + /// Resets the current track + public func reset() { + guard trackingState == .notTracking else { + assertionFailure("reset() cannot be called when tracking") + return GTError(message: "reset() cannot be called when tracking") + } + lastPoint = nil + track = nil + } + /// Attempts to start tracking (if we're not already). public func startTracking() { GTInfo(message: "User requested Start Tracking") @@ -72,6 +94,7 @@ extension GeoTrackManager: GeoTrackService { initializeLocationManager() beginLocationUpdates() trackingState = .awaitingFix + NotificationCenter.default.post(name: Notification.GeoTrackKit.trackingStarted, object: nil) } /// Stops tracking @@ -80,6 +103,7 @@ extension GeoTrackManager: GeoTrackService { endLocationUpdates() trackingState = .notTracking + NotificationCenter.default.post(name: Notification.GeoTrackKit.trackingStopped, object: nil) } } @@ -133,7 +157,7 @@ extension GeoTrackManager: CLLocationManagerDelegate { // Ensure that the first point is recent (not old points which we often get when tracking begins): if lastPoint == nil { - locations.forEach { (location) in + locations.filter({ $0.isAccurateEnough }).forEach { location in guard abs(location.timestamp.timeIntervalSinceNow) < GeoTrackManager.oldPointThreshold else { return } @@ -143,7 +167,7 @@ extension GeoTrackManager: CLLocationManagerDelegate { return } } else { - recentLocations = locations + recentLocations = locations.filter { $0.isAccurateEnough } } GTDebug(message: "New Locations: \(recentLocations)") @@ -158,7 +182,7 @@ extension GeoTrackManager: CLLocationManagerDelegate { return } track.add(locations: recentLocations) - NotificationCenter.default.post(name: Notification.Name.GeoTrackKit.didUpdateLocations, object: recentLocations) + NotificationCenter.default.post(name: Notification.GeoTrackKit.didUpdateLocations, object: recentLocations) } /// Handles location tracking pauses @@ -167,7 +191,7 @@ extension GeoTrackManager: CLLocationManagerDelegate { public func locationManagerDidPauseLocationUpdates(_ manager: CLLocationManager) { GTDebug(message: "Paused Location Updates") track?.pauseTracking(message: "locationManagerDidPauseLocationUpdates event") - NotificationCenter.default.post(name: Notification.Name.GeoTrackKit.didPauseLocationUpdates, object: nil) + NotificationCenter.default.post(name: Notification.GeoTrackKit.didPauseLocationUpdates, object: nil) } /// Handles location tracking resuming. @@ -176,7 +200,7 @@ extension GeoTrackManager: CLLocationManagerDelegate { public func locationManagerDidResumeLocationUpdates(_ manager: CLLocationManager) { GTDebug(message: "Resumed Location Updates") track?.startTracking(message: "locationManagerDidResumeLocationUpdates event") - NotificationCenter.default.post(name: Notification.Name.GeoTrackKit.didResumeLocationUpdates, object: nil) + NotificationCenter.default.post(name: Notification.GeoTrackKit.didResumeLocationUpdates, object: nil) } /// Handles location tracking errors @@ -187,7 +211,7 @@ extension GeoTrackManager: CLLocationManagerDelegate { public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { GTError(message: "Failed to perform location tracking: \(error.localizedDescription), \(error)") track?.error(error: error) - NotificationCenter.default.post(name: Notification.Name.GeoTrackKit.didFailWithError, object: error) + NotificationCenter.default.post(name: Notification.GeoTrackKit.didFailWithError, object: error) } /// Handles deferred update errors. @@ -205,14 +229,14 @@ extension GeoTrackManager: CLLocationManagerDelegate { } else { track?.error(message: "locationManager:didFinishDeferredUpdatesWithError: nil error") } - NotificationCenter.default.post(name: Notification.Name.GeoTrackKit.didFinishDeferredUpdatesWithError, object: error) + NotificationCenter.default.post(name: Notification.GeoTrackKit.didFinishDeferredUpdatesWithError, object: error) } } // MARK: - Helpers -fileprivate extension GeoTrackManager { +private extension GeoTrackManager { /// Initializes the location manager and sets the preferences func initializeLocationManager() { @@ -221,6 +245,14 @@ fileprivate extension GeoTrackManager { } let locationManager = CLLocationManager() + setLocationManager(locationManager) + } + + /// Configures the locationManager + func configureLocationManager() { + guard let locationManager = locationManager else { + return + } locationManager.activityType = .fitness locationManager.desiredAccuracy = kCLLocationAccuracyBest @@ -231,8 +263,6 @@ fileprivate extension GeoTrackManager { locationManager.distanceFilter = 10 locationManager.allowsBackgroundLocationUpdates = true locationManager.delegate = self - - self.locationManager = locationManager } /// Handles requesting always authorization from location services @@ -265,12 +295,29 @@ fileprivate extension GeoTrackManager { } +// MARK: - CLLocation + +extension CLLocation { + + /// Is the accuracy of the point within the acceptable range? + var isAccurateEnough: Bool { + return horizontalAccuracy <= 50 + } +} + // MARK: - Notifications -public extension Notification.Name { +public extension Notification { /// GeoTrackKit notification constants public struct GeoTrackKit { + + /// Notification that the user has started tracking + public static let trackingStarted = Notification.Name(rawValue: "com.geotrackkit.user.started.tracking") + + /// Notification that the user has stopped tracking + public static let trackingStopped = Notification.Name(rawValue: "com.geotrackkit.user.stopped.tracking") + /// Notofication that the location was updated public static let didUpdateLocations = Notification.Name(rawValue: "com.geotrackkit.did.update.locations") diff --git a/GeoTrackKit/Core/GeoTrackService.swift b/GeoTrackKit/Core/GeoTrackService.swift index e672ed1..80159c8 100644 --- a/GeoTrackKit/Core/GeoTrackService.swift +++ b/GeoTrackKit/Core/GeoTrackService.swift @@ -32,6 +32,9 @@ public protocol GeoTrackService { /// The most recently tracked point var lastPoint: CLLocation? { get } + /// Resets the current track + func reset() + /// Starts tracking func startTracking() diff --git a/GeoTrackKit/Core/Map/UIModels/UIGeoTrack.swift b/GeoTrackKit/Core/Map/UIModels/UIGeoTrack.swift index d8f74f9..528c9a3 100644 --- a/GeoTrackKit/Core/Map/UIModels/UIGeoTrack.swift +++ b/GeoTrackKit/Core/Map/UIModels/UIGeoTrack.swift @@ -6,6 +6,7 @@ // Copyright © 2017 Eric Internicola. All rights reserved. // +import CoreLocation import Foundation /// A UI Model for a track. It keeps track of a Track (`GeoTrack`), a Track Analyzer (`GeoTrackAnalyzer`) and a collection of Legs (ascents, descents) that are currently visible @@ -86,6 +87,35 @@ public extension UIGeoTrack { return analyzer.legs } + /// Gets you an array of points for the provided leg + /// + /// - Parameter index: The index of the leg you want to get the points for. + /// - Returns: An array of CLLocation objects which are the points for the leg. + func getPoints(forLeg index: Int) -> [CLLocation]? { + guard index < allLegs.count else { + return nil + } + let leg = allLegs[index] + return getPoints(for: leg) + } + + /// Gets you an array of points for the provided leg + /// + /// - Parameter leg: The leg that you want the points for. + /// - Returns: An array of CLLocation objects which are teh points for the leg. + func getPoints(for leg: Leg) -> [CLLocation]? { + let range = leg.index...leg.endIndex + + return Array(track.points[range]) + } + + var startDate: Date? { + return track.points.first?.timestamp + } + + var endDate: Date? { + return track.points.last?.timestamp + } } // MARK: - Helpers diff --git a/GeoTrackKit/HealthKit/ActivityService.swift b/GeoTrackKit/HealthKit/ActivityService.swift index b66d828..ece10b4 100644 --- a/GeoTrackKit/HealthKit/ActivityService.swift +++ b/GeoTrackKit/HealthKit/ActivityService.swift @@ -29,12 +29,34 @@ public typealias TrackCallback = ([CLLocation]?, Error?) -> Void /// `HKWorkoutRoute` is a `HKSeriesSample` /// /// A typical workflow goes like this: -/// 1. Ensure we are authorized: `ActivityService.shared.authorize { //...` -/// 2. Query for the workouts: `ActivityService.shared.queryWorkouts { //...` -/// 3. Filter down to workouts that have Routes: `ActivityService.shared.queryRoute(from: workout) { // ...` -/// 4. Get the Track (Route) for a workout: `ActivityService.shared.queryTrack(from: workout) { // ...` +/// 1. Set Access Type: `` +/// 2. Ensure we are authorized: `ActivityService.shared.authorize { //...` +/// 3. Query for the workouts: `ActivityService.shared.queryWorkouts { //...` +/// 4. Filter down to workouts that have Routes: `ActivityService.shared.queryRoute(from: workout) { // ...` +/// 5. Get the Track (Route) for a workout: `ActivityService.shared.queryTrack(from: workout) { // ...` public class ActivityService { + /// Access modes for HealthKit data + /// + /// - readonly: I only wish to read data from health kit. + /// - writeonly: I only wish to write data to health kit. + /// - readwrite: I wish to read and write data to health kit. + public enum AccessType { + case readonly + case writeonly + case readwrite + + /// Does this access type have read access? + var readAccess: Bool { + return [AccessType.readonly, AccessType.readwrite].contains(self) + } + + /// Does this access type have write access? + var writeAccess: Bool { + return [AccessType.writeonly, AccessType.readwrite].contains(self) + } + } + /// Shared (singleton) instance of the `ActivityService` public static let shared = ActivityService() @@ -46,13 +68,15 @@ public class ActivityService { /// The HealthKitStore (if available) private var store = HKHealthStore() + /// The way in which you wish to access data with HealthKit. + public var accessType = AccessType.readwrite + } // MARK: - Authorization API extension ActivityService { - /// Request authorization for health kit data. /// /// - Parameter callback: the callback that will hand back a boolean (indicating success or failure) @@ -67,13 +91,15 @@ extension ActivityService { return callback(false, GeoTrackKitError.iOS11Required) } - let allTypes = Set([ + let workoutsWithRoutes = Set([ HKObjectType.workoutType(), - HKObjectType.activitySummaryType(), HKObjectType.seriesType(forIdentifier: HKWorkoutRouteTypeIdentifier)! ]) - store.requestAuthorization(toShare: nil, read: allTypes) { (success, error) in + let shareMode = accessType.writeAccess ? workoutsWithRoutes : nil + let readMode = accessType.readAccess ? workoutsWithRoutes : nil + + store.requestAuthorization(toShare: shareMode, read: readMode) { (success, error) in if let error = error { callback(success, error) return GTError(message: "Could not get health store authorization: \(error.localizedDescription)") @@ -90,7 +116,94 @@ extension ActivityService { } -// MARK: - Workout Queries +// MARK: - Write APIs + +extension ActivityService { + + /// Saves the provided track to HealthKit + /// + /// - Parameter model: the model that contains the points, legs, etc to be + /// saved in HealthKit. + public func saveTrack(_ model: UIGeoTrack, for activity: HKWorkoutActivityType) { + guard let startDate = model.startDate, let endDate = model.endDate, let distance = model.analyzer.stats?.totalDistance else { + return assertionFailure("No start / end date") + } + let elapsed = endDate.timeIntervalSince1970 - startDate.timeIntervalSince1970 + let workout = HKWorkout(activityType: activity, start: startDate, + end: endDate, + duration: elapsed, + totalEnergyBurned: nil, + totalDistance: HKQuantity(unit: HKUnit.meter(), doubleValue: distance), metadata: nil) + + store.save(workout) { (success, error) in + if let error = error { + return GTError(message: error.localizedDescription) + } + self.helpSaveTrack(model, workout: workout) + } + } + + private func helpSaveTrack(_ model: UIGeoTrack, workout: HKWorkout) { + var savedLegs = 0 + var failure = false + for leg in model.allLegs { + guard let points = model.getPoints(for: leg) else { + savedLegs += 1 + failure = true + continue + } + let builder = HKWorkoutRouteBuilder(healthStore: store, device: nil) + builder.insertRouteData(points) { (success, error) in + if let error = error { + savedLegs += 1 + failure = true + return GTError(message: error.localizedDescription) + } + + builder.finishRoute(with: workout, metadata: model.metadata(for: leg), completion: { (route, error) in + savedLegs += 1 + if let error = error { + failure = true + return GTError(message: error.localizedDescription) + } + + }) + } + } + } + +} + +// MARK: - UIGeoTrack extensions + +extension UIGeoTrack { + + func averageSpeed(for leg: Leg) -> CLLocationSpeed { + guard let points = getPoints(for: leg) else { + return 0 + } + + return points.reduce(0, { $0 + $1.speed }) / CLLocationSpeed(points.count) + } + + func metadata(for leg: Leg) -> [String: Any]? { + var metamap = [String: Any]() + + if #available(iOS 11.2, *) { + if leg.direction == .downward { + metamap[HKMetadataKeyElevationDescended] = abs(leg.altitudeChange) + } else if leg.direction == .upward { + metamap[HKMetadataKeyElevationAscended] = abs(leg.altitudeChange) + } + metamap[HKMetadataKeyMaximumSpeed] = leg.stat.maximumSpeed + metamap[HKMetadataKeyAverageSpeed] = averageSpeed(for: leg) + } + + return metamap + } +} + +// MARK: - Read APIs extension ActivityService { diff --git a/GeoTrackKitExample/GeoTrackKitExample.xcodeproj/project.pbxproj b/GeoTrackKitExample/GeoTrackKitExample.xcodeproj/project.pbxproj index a90b3ec..18dbd74 100644 --- a/GeoTrackKitExample/GeoTrackKitExample.xcodeproj/project.pbxproj +++ b/GeoTrackKitExample/GeoTrackKitExample.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ 1B2451A1B46FD1E813C13A25 /* Pods_GeoTrackKitExampleTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6E72B23B9554498B66030FE /* Pods_GeoTrackKitExampleTests.framework */; }; 3604FC77217150AB00B46BAA /* GeoTrackStatisticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3604FC76217150AB00B46BAA /* GeoTrackStatisticsTests.swift */; }; 36302DB9210E17B400834A1D /* GeoTrackKitErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36302DB8210E17B400834A1D /* GeoTrackKitErrorTests.swift */; }; + 3665F7D521176FA200B58BBA /* TrackFileService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3665F7D421176FA200B58BBA /* TrackFileService.swift */; }; + 36682DFF2117BC0C0076A6A8 /* TrackListTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36682DFE2117BC0C0076A6A8 /* TrackListTableViewController.swift */; }; 366CE43C1DFCAC360090BD42 /* GeoTrackSerializationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 366CE43B1DFCAC360090BD42 /* GeoTrackSerializationTests.swift */; }; 368A87AE1E6332C1003D115A /* reference-track-1.json in Resources */ = {isa = PBXBuildFile; fileRef = 368A87AD1E6332C1003D115A /* reference-track-1.json */; }; 368A87B11E63332E003D115A /* TrackReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 368A87B01E63332E003D115A /* TrackReader.swift */; }; @@ -25,6 +27,8 @@ 36A7C6E52104CC690073407A /* TrackOverviewTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36A7C6E42104CC690073407A /* TrackOverviewTableViewController.swift */; }; 36A7C6E72104CD120073407A /* TrackView.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 36A7C6E62104CD120073407A /* TrackView.storyboard */; }; 36A7C6E92104D8870073407A /* LiveTrackingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36A7C6E82104D8870073407A /* LiveTrackingViewController.swift */; }; + 36B5A61C21173EBA008D8E5D /* SaveTrackCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36B5A61B21173EBA008D8E5D /* SaveTrackCell.swift */; }; + 36B5A61F2117BA60008D8E5D /* TrackList.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 36B5A61E2117BA60008D8E5D /* TrackList.storyboard */; }; 36C45E451DCE2D3500E87710 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36C45E441DCE2D3500E87710 /* AppDelegate.swift */; }; 36C45E4A1DCE2D3500E87710 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 36C45E481DCE2D3500E87710 /* Main.storyboard */; }; 36C45E4C1DCE2D3500E87710 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 36C45E4B1DCE2D3500E87710 /* Assets.xcassets */; }; @@ -52,6 +56,8 @@ /* Begin PBXFileReference section */ 3604FC76217150AB00B46BAA /* GeoTrackStatisticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoTrackStatisticsTests.swift; sourceTree = ""; }; 36302DB8210E17B400834A1D /* GeoTrackKitErrorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeoTrackKitErrorTests.swift; sourceTree = ""; }; + 3665F7D421176FA200B58BBA /* TrackFileService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackFileService.swift; sourceTree = ""; }; + 36682DFE2117BC0C0076A6A8 /* TrackListTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackListTableViewController.swift; sourceTree = ""; }; 366CE43B1DFCAC360090BD42 /* GeoTrackSerializationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeoTrackSerializationTests.swift; sourceTree = ""; }; 368A87AD1E6332C1003D115A /* reference-track-1.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "reference-track-1.json"; sourceTree = ""; }; 368A87B01E63332E003D115A /* TrackReader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrackReader.swift; sourceTree = ""; }; @@ -66,6 +72,8 @@ 36A7C6E42104CC690073407A /* TrackOverviewTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TrackOverviewTableViewController.swift; sourceTree = ""; }; 36A7C6E62104CD120073407A /* TrackView.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = TrackView.storyboard; sourceTree = ""; }; 36A7C6E82104D8870073407A /* LiveTrackingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTrackingViewController.swift; sourceTree = ""; }; + 36B5A61B21173EBA008D8E5D /* SaveTrackCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveTrackCell.swift; sourceTree = ""; }; + 36B5A61E2117BA60008D8E5D /* TrackList.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = TrackList.storyboard; sourceTree = ""; }; 36C45E411DCE2D3500E87710 /* GeoTrackKitExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GeoTrackKitExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 36C45E441DCE2D3500E87710 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 36C45E491DCE2D3500E87710 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; @@ -151,6 +159,7 @@ children = ( 36A7C6C62100B0980073407A /* EventLogAppender.swift */, 36A7C6CF2103ECB40073407A /* ConsoleLogAppender.swift */, + 3665F7D421176FA200B58BBA /* TrackFileService.swift */, ); path = Services; sourceTree = ""; @@ -158,6 +167,7 @@ 36A7C6D52104C7480073407A /* Views */ = { isa = PBXGroup; children = ( + 36B5A61D2117BA2D008D8E5D /* TrackList */, 36D099B3210B64D900C8C841 /* TrackImport */, 36A7C6D72104C7480073407A /* TrackConsole */, 36A7C6D82104C7480073407A /* ReferenceTrack */, @@ -182,6 +192,7 @@ 36A7C6E42104CC690073407A /* TrackOverviewTableViewController.swift */, 36A7C6DE2104CC0D0073407A /* LegSwitchCell.swift */, 36A7C6DF2104CC0D0073407A /* TrackMapViewController.swift */, + 36B5A61B21173EBA008D8E5D /* SaveTrackCell.swift */, ); path = ReferenceTrack; sourceTree = ""; @@ -195,6 +206,15 @@ path = LiveTracking; sourceTree = ""; }; + 36B5A61D2117BA2D008D8E5D /* TrackList */ = { + isa = PBXGroup; + children = ( + 36B5A61E2117BA60008D8E5D /* TrackList.storyboard */, + 36682DFE2117BC0C0076A6A8 /* TrackListTableViewController.swift */, + ); + path = TrackList; + sourceTree = ""; + }; 36C45E381DCE2D3500E87710 = { isa = PBXGroup; children = ( @@ -400,6 +420,7 @@ 36A7C6DB2104CBF30073407A /* TrackConsole.storyboard in Resources */, 36C45E4F1DCE2D3500E87710 /* LaunchScreen.storyboard in Resources */, 36E3C43D1F4A0817005738DB /* reference-track-1.json in Resources */, + 36B5A61F2117BA60008D8E5D /* TrackList.storyboard in Resources */, 36A7C6E72104CD120073407A /* TrackView.storyboard in Resources */, 36C45E4C1DCE2D3500E87710 /* Assets.xcassets in Resources */, 36D099B5210B64E200C8C841 /* TrackImport.storyboard in Resources */, @@ -496,12 +517,15 @@ files = ( 36A7C6E02104CC0D0073407A /* LegSwitchCell.swift in Sources */, 36A7C6E52104CC690073407A /* TrackOverviewTableViewController.swift in Sources */, + 3665F7D521176FA200B58BBA /* TrackFileService.swift in Sources */, 36A7C6E12104CC0D0073407A /* TrackMapViewController.swift in Sources */, 36A7C6DD2104CBFE0073407A /* TrackConsoleViewController.swift in Sources */, + 36682DFF2117BC0C0076A6A8 /* TrackListTableViewController.swift in Sources */, 36D099B7210B6A2C00C8C841 /* TrackImportTableViewController.swift in Sources */, 36A7C6E92104D8870073407A /* LiveTrackingViewController.swift in Sources */, 36C45E451DCE2D3500E87710 /* AppDelegate.swift in Sources */, 36A7C6C72100B0980073407A /* EventLogAppender.swift in Sources */, + 36B5A61C21173EBA008D8E5D /* SaveTrackCell.swift in Sources */, 36A7C6D02103ECB40073407A /* ConsoleLogAppender.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/GeoTrackKitExample/GeoTrackKitExample/AppDelegate.swift b/GeoTrackKitExample/GeoTrackKitExample/AppDelegate.swift index 5fab04e..2092755 100644 --- a/GeoTrackKitExample/GeoTrackKitExample/AppDelegate.swift +++ b/GeoTrackKitExample/GeoTrackKitExample/AppDelegate.swift @@ -21,6 +21,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { GeoTrackEventLog.shared.add(appender: ConsoleLogAppender.shared) #endif + // Bootstrap the TrackFileService + TrackFileService.shared + return true } diff --git a/GeoTrackKitExample/GeoTrackKitExample/Assets.xcassets/track_list.imageset/Contents.json b/GeoTrackKitExample/GeoTrackKitExample/Assets.xcassets/track_list.imageset/Contents.json new file mode 100644 index 0000000..e4e5f77 --- /dev/null +++ b/GeoTrackKitExample/GeoTrackKitExample/Assets.xcassets/track_list.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "track_list.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "track_list@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "track_list@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/GeoTrackKitExample/GeoTrackKitExample/Assets.xcassets/track_list.imageset/track_list.png b/GeoTrackKitExample/GeoTrackKitExample/Assets.xcassets/track_list.imageset/track_list.png new file mode 100644 index 0000000..f2736cd Binary files /dev/null and b/GeoTrackKitExample/GeoTrackKitExample/Assets.xcassets/track_list.imageset/track_list.png differ diff --git a/GeoTrackKitExample/GeoTrackKitExample/Assets.xcassets/track_list.imageset/track_list@2x.png b/GeoTrackKitExample/GeoTrackKitExample/Assets.xcassets/track_list.imageset/track_list@2x.png new file mode 100644 index 0000000..0cfe7bb Binary files /dev/null and b/GeoTrackKitExample/GeoTrackKitExample/Assets.xcassets/track_list.imageset/track_list@2x.png differ diff --git a/GeoTrackKitExample/GeoTrackKitExample/Assets.xcassets/track_list.imageset/track_list@3x.png b/GeoTrackKitExample/GeoTrackKitExample/Assets.xcassets/track_list.imageset/track_list@3x.png new file mode 100644 index 0000000..797a863 Binary files /dev/null and b/GeoTrackKitExample/GeoTrackKitExample/Assets.xcassets/track_list.imageset/track_list@3x.png differ diff --git a/GeoTrackKitExample/GeoTrackKitExample/Base.lproj/Main.storyboard b/GeoTrackKitExample/GeoTrackKitExample/Base.lproj/Main.storyboard index 57a0c63..58350cf 100644 --- a/GeoTrackKitExample/GeoTrackKitExample/Base.lproj/Main.storyboard +++ b/GeoTrackKitExample/GeoTrackKitExample/Base.lproj/Main.storyboard @@ -4,7 +4,6 @@ - @@ -19,6 +18,7 @@ + @@ -49,6 +49,16 @@ + + + + + + + + + + diff --git a/GeoTrackKitExample/GeoTrackKitExample/Info.plist b/GeoTrackKitExample/GeoTrackKitExample/Info.plist index bc7728b..a853cab 100644 --- a/GeoTrackKitExample/GeoTrackKitExample/Info.plist +++ b/GeoTrackKitExample/GeoTrackKitExample/Info.plist @@ -2,6 +2,8 @@ + NSHealthUpdateUsageDescription + Write workout routes to your health data. CFBundleDevelopmentRegion en CFBundleDisplayName diff --git a/GeoTrackKitExample/GeoTrackKitExample/Services/TrackFileService.swift b/GeoTrackKitExample/GeoTrackKitExample/Services/TrackFileService.swift new file mode 100644 index 0000000..d71bea9 --- /dev/null +++ b/GeoTrackKitExample/GeoTrackKitExample/Services/TrackFileService.swift @@ -0,0 +1,90 @@ +// +// TrackFileService.swift +// GeoTrackKitExample +// +// Created by Eric Internicola on 8/5/18. +// Copyright © 2018 Eric Internicola. All rights reserved. +// + +import Foundation +import GeoTrackKit + +class TrackFileService { + static let shared = TrackFileService() + + var documents: String { + return fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0].path + } + + /// Gets you the track files in the folder + /// + /// - Returns: a list of track files in the folder + var trackFiles: [String] { + // Full path to documents directory + let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0].path + + // List all contents of directory and return as [String] OR nil if failed + let fileList = (try? fileManager.contentsOfDirectory(atPath: docs).filter({ $0.hasSuffix(Constants.trackSuffix) })) ?? [] + return fileList.sorted(by: { $0 > $1 }) + } + + /// Saves the provided track to a file. + /// + /// - Parameter track: The track to save. + func save(track: UIGeoTrack) { + guard let startDate = track.startDate else { + return assertionFailure("Failed to get start date from track") + } + let fileName = Constants.formatter.string(from: startDate) + Constants.trackSuffix + let fullPath = "\(documents)/\(fileName)" + + guard let jsonData = try? JSONSerialization.data(withJSONObject: track.track.map, options: []) else { + return assertionFailure("Failed to create JSON Data for track") + } + + let fileUrl = URL(fileURLWithPath: fullPath) + do { + try jsonData.write(to: fileUrl) + print("Wrote new track file: \(fileUrl.absoluteString)") + } catch { + print("ERROR: \(error.localizedDescription)") + } + } + + init() { + NotificationCenter.default.addObserver(self, selector: #selector(stoppedTracking(_:)), name: Notification.GeoTrackKit.trackingStopped, object: nil) + } +} + +// MARK: - Event Handlers + +extension TrackFileService { + + @objc + func stoppedTracking(_ notification: NSNotification) { + guard let track = GeoTrackManager.shared.track else { + return assertionFailure("Failed to locate a track to save") + } + + self.save(track: UIGeoTrack(with: track)) + } +} + +// MARK: - Implementation + +private extension TrackFileService { + + struct Constants { + static let trackSuffix = "-track.json" + static let formatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy_MMM_dd_HH_mm_ss" + return dateFormatter + }() + } + + var fileManager: FileManager { + return FileManager.default + } + +} diff --git a/GeoTrackKitExample/GeoTrackKitExample/Views/LiveTracking/LiveTrackingViewController.swift b/GeoTrackKitExample/GeoTrackKitExample/Views/LiveTracking/LiveTrackingViewController.swift index 427717b..0c82443 100644 --- a/GeoTrackKitExample/GeoTrackKitExample/Views/LiveTracking/LiveTrackingViewController.swift +++ b/GeoTrackKitExample/GeoTrackKitExample/Views/LiveTracking/LiveTrackingViewController.swift @@ -20,7 +20,7 @@ class LiveTrackingViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - NotificationCenter.default.addObserver(self, selector: #selector(trackUpdated), name: Notification.Name.GeoTrackKit.didUpdateLocations, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(trackUpdated), name: Notification.GeoTrackKit.didUpdateLocations, object: nil) } } diff --git a/GeoTrackKitExample/GeoTrackKitExample/Views/ReferenceTrack/SaveTrackCell.swift b/GeoTrackKitExample/GeoTrackKitExample/Views/ReferenceTrack/SaveTrackCell.swift new file mode 100644 index 0000000..b705e59 --- /dev/null +++ b/GeoTrackKitExample/GeoTrackKitExample/Views/ReferenceTrack/SaveTrackCell.swift @@ -0,0 +1,22 @@ +// +// SaveTrackCell.swift +// GeoTrackKitExample +// +// Created by Eric Internicola on 8/5/18. +// Copyright © 2018 Eric Internicola. All rights reserved. +// + +import UIKit + +class SaveTrackCell: UITableViewCell { + + struct Constants { + static let saveTrackNotification = Notification.Name(rawValue: "tapped.save.track") + } + + @IBAction + func tappedSaveTrack(_ sender: Any) { + NotificationCenter.default.post(name: Constants.saveTrackNotification, object: nil) + } + +} diff --git a/GeoTrackKitExample/GeoTrackKitExample/Views/ReferenceTrack/TrackMapViewController.swift b/GeoTrackKitExample/GeoTrackKitExample/Views/ReferenceTrack/TrackMapViewController.swift index deb538e..34696f5 100644 --- a/GeoTrackKitExample/GeoTrackKitExample/Views/ReferenceTrack/TrackMapViewController.swift +++ b/GeoTrackKitExample/GeoTrackKitExample/Views/ReferenceTrack/TrackMapViewController.swift @@ -21,6 +21,7 @@ class TrackMapViewController: UIViewController { } var useDemoTrack = true + var legVisibleByDefault: Bool { return !useDemoTrack } @@ -147,6 +148,7 @@ extension TrackMapViewController { } tableVC = destinationVC tableVC?.model = model + tableVC?.showSaveTrackCell = useDemoTrack } } diff --git a/GeoTrackKitExample/GeoTrackKitExample/Views/ReferenceTrack/TrackOverviewTableViewController.swift b/GeoTrackKitExample/GeoTrackKitExample/Views/ReferenceTrack/TrackOverviewTableViewController.swift index 5329463..038d649 100644 --- a/GeoTrackKitExample/GeoTrackKitExample/Views/ReferenceTrack/TrackOverviewTableViewController.swift +++ b/GeoTrackKitExample/GeoTrackKitExample/Views/ReferenceTrack/TrackOverviewTableViewController.swift @@ -7,10 +7,15 @@ // import GeoTrackKit +import HealthKit import UIKit class TrackOverviewTableViewController: UITableViewController { + /// Should the "Save Track Cell" be visible? (For Demo Track) + var showSaveTrackCell = false + + /// Sets the Track Model var model: UIGeoTrack? { didSet { tableView.reloadData() @@ -25,6 +30,23 @@ class TrackOverviewTableViewController: UITableViewController { super.viewDidLoad() tableView.estimatedRowHeight = 45 tableView.rowHeight = UITableView.automaticDimension + + NotificationCenter.default.addObserver(self, selector: #selector(saveTrack(_:)), name: SaveTrackCell.Constants.saveTrackNotification, object: nil) + } + +} + +// MARK: - Notification Handlers + +extension TrackOverviewTableViewController { + + @objc + func saveTrack(_ notification: NSNotification) { + print("You tapped Save Track") + guard let model = model else { + return + } + ActivityService.shared.saveTrack(model, for: HKWorkoutActivityType.downhillSkiing) } } @@ -34,17 +56,27 @@ class TrackOverviewTableViewController: UITableViewController { extension TrackOverviewTableViewController { override func numberOfSections(in tableView: UITableView) -> Int { - return 1 + return 2 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - guard let analyzer = analyzer else { - return 0 + switch section { + case 0: + return showSaveTrackCell ? 1 : 0 + + default: + guard let analyzer = analyzer else { + return 0 + } + return analyzer.legs.count } - return analyzer.legs.count + } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard indexPath.section == 1 else { + return tableView.dequeueReusableCell(withIdentifier: "SaveTrackCell", for: indexPath) + } let cell = tableView.dequeueReusableCell(withIdentifier: "LegSwitchCell", for: indexPath) guard let model = model, let legCell = cell as? LegSwitchCell else { diff --git a/GeoTrackKitExample/GeoTrackKitExample/Views/ReferenceTrack/TrackView.storyboard b/GeoTrackKitExample/GeoTrackKitExample/Views/ReferenceTrack/TrackView.storyboard index 947ef2e..0e3be1d 100644 --- a/GeoTrackKitExample/GeoTrackKitExample/Views/ReferenceTrack/TrackView.storyboard +++ b/GeoTrackKitExample/GeoTrackKitExample/Views/ReferenceTrack/TrackView.storyboard @@ -1,11 +1,11 @@ - + - - + + @@ -14,18 +14,22 @@ + + + + - + - + - + @@ -33,25 +37,18 @@ - - - + + + - - - + + + - + - - - - - - - - + @@ -66,19 +63,43 @@ - + - + + + + + + + + + + + + + + + + + + + - + diff --git a/GeoTrackKitExample/GeoTrackKitExample/Views/TrackConsole/TrackConsoleViewController.swift b/GeoTrackKitExample/GeoTrackKitExample/Views/TrackConsole/TrackConsoleViewController.swift index 9122b89..820abf5 100644 --- a/GeoTrackKitExample/GeoTrackKitExample/Views/TrackConsole/TrackConsoleViewController.swift +++ b/GeoTrackKitExample/GeoTrackKitExample/Views/TrackConsole/TrackConsoleViewController.swift @@ -28,7 +28,7 @@ class TrackConsoleViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - NotificationCenter.default.addObserver(self, selector: #selector(locationDidUpdate(_:)), name: Notification.Name.GeoTrackKit.didUpdateLocations, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(locationDidUpdate(_:)), name: Notification.GeoTrackKit.didUpdateLocations, object: nil) } @IBAction @@ -58,6 +58,7 @@ fileprivate extension TrackConsoleViewController { if GeoTrackManager.shared.isTracking { GeoTrackManager.shared.stopTracking() } else { + GeoTrackManager.shared.reset() GeoTrackManager.shared.startTracking() } updateButtonText() diff --git a/GeoTrackKitExample/GeoTrackKitExample/Views/TrackImport/TrackImportTableViewController.swift b/GeoTrackKitExample/GeoTrackKitExample/Views/TrackImport/TrackImportTableViewController.swift index 069e289..c2c5e11 100644 --- a/GeoTrackKitExample/GeoTrackKitExample/Views/TrackImport/TrackImportTableViewController.swift +++ b/GeoTrackKitExample/GeoTrackKitExample/Views/TrackImport/TrackImportTableViewController.swift @@ -18,7 +18,10 @@ class TrackImportTableViewController: UITableViewController { override func viewDidLoad() { super.viewDidLoad() + } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) loadTracksFromWorkouts() } @@ -74,6 +77,7 @@ extension TrackImportTableViewController { /// Loads the tracks from the workouts for you. func loadTracksFromWorkouts() { + workouts.removeAll() ActivityService.shared.authorize { (success, _) in guard success else { print("We won't be querying activities, no authorization") diff --git a/GeoTrackKitExample/GeoTrackKitExample/Views/TrackList/TrackList.storyboard b/GeoTrackKitExample/GeoTrackKitExample/Views/TrackList/TrackList.storyboard new file mode 100644 index 0000000..5397813 --- /dev/null +++ b/GeoTrackKitExample/GeoTrackKitExample/Views/TrackList/TrackList.storyboard @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GeoTrackKitExample/GeoTrackKitExample/Views/TrackList/TrackListTableViewController.swift b/GeoTrackKitExample/GeoTrackKitExample/Views/TrackList/TrackListTableViewController.swift new file mode 100644 index 0000000..c262e3f --- /dev/null +++ b/GeoTrackKitExample/GeoTrackKitExample/Views/TrackList/TrackListTableViewController.swift @@ -0,0 +1,109 @@ +// +// TrackListTableViewController.swift +// GeoTrackKitExample +// +// Created by Eric Internicola on 8/5/18. +// Copyright © 2018 Eric Internicola. All rights reserved. +// + +import GeoTrackKit +import UIKit + +class TrackListTableViewController: UITableViewController { + + var trackList = [String]() + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + trackList = TrackFileService.shared.trackFiles + tableView.reloadData() + tableView.tableFooterView = UIView() + } + + // MARK: - Table view data source + + override func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + guard trackList.count > 0 else { + return 1 + } + return trackList.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard trackList.count > 0 else { + let cell = UITableViewCell() + cell.textLabel?.text = "No tracks yet" + cell.textLabel?.textAlignment = .center + return cell + } + let cell = tableView.dequeueReusableCell(withIdentifier: "TrackCell", for: indexPath) + cell.textLabel?.text = trackList[indexPath.row] + return cell + } + + override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + let actionConfig = UISwipeActionsConfiguration(actions: [ + UIContextualAction(style: .normal, title: "Share") { _, _, _ in + self.shareTrack(indexPath) + tableView.setEditing(false, animated: true) + } + ]) + + return actionConfig + } + + override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { + return nil + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard trackList.count > 0 else { + return + } + let filePath = "\(TrackFileService.shared.documents)/\(trackList[indexPath.row])" + guard let data = try? Data(contentsOf: URL(fileURLWithPath: filePath)) else { + return assertionFailure("Failed to open file: \(trackList[indexPath.row])") + } + guard let jsonData = try? JSONSerialization.jsonObject(with: data, options: []) else { + return assertionFailure("Failed to read json content from: \(filePath)") + } + guard let jsonMap = jsonData as? [String: Any], let track = GeoTrack.fromMap(map: jsonMap) else { + return assertionFailure("Wrong data type") + } + + let map = TrackMapViewController.loadFromStoryboard() + map.model = UIGeoTrack(with: track) + map.useDemoTrack = false + + navigationController?.pushViewController(map, animated: true) + } + +} + +// MARK: - Implementation + +extension TrackListTableViewController { + + func shareTrack(_ indexPath: IndexPath) { + guard let filename = filename(forIndex: indexPath) else { + return print("No filename") + } + let fileURL = URL(fileURLWithPath: filename) + print("User wants to share file: \(filename)") + let activityVC = UIActivityViewController(activityItems: [fileURL], applicationActivities: nil) + + self.present(activityVC, animated: true, completion: nil) + + } + + func filename(forIndex indexPath: IndexPath) -> String? { + guard indexPath.row < trackList.count else { + return nil + } + return "\(TrackFileService.shared.documents)/\(trackList[indexPath.row])" + } +} diff --git a/README.md b/README.md index 50cc4e5..bf36205 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,9 @@ - Pull tracks in from HealthKit (Activity App) - `NOTE:` This is an iOS 11+ only feature and requires a physical device to test - This capability is in a subspec: `HealthKit` +- Write tracks to HealthKit + - `NOTE:` This is an iOS 11+ only feature and requires a physical device to test + - This capability is in a subspec: `HealthKit` - Example App to demonstrate capabilities @@ -35,7 +38,7 @@ This project is currently a work in progress. ### Example App TODO: -- [ ] Save tracks to disk +- [x] Save tracks to disk - [ ] Provide a track list - [x] Pull tracks in from HealthKit @@ -90,3 +93,7 @@ See the generated documentation in the [`docs`](https://intere.github.io/GeoTrac -
Icons made by Freepik from www.flaticon.com is licensed by CC 3.0 BY
- [Map Location free icon](https://www.flaticon.com/free-icon/map-location_149985#term=gps&page=1&position=24) -
Icons made by Smashicons from www.flaticon.com is licensed by CC 3.0 BY
+- [List Icon](https://www.flaticon.com/free-icon/list_149367#term=listing&page=1&position=2) + -
Icons made by Smashicons from www.flaticon.com is licensed by CC 3.0 BY
+ +I've taken the above images and converted them into less than 50x50 icons to use for tabs