├── .gitmodules
├── activities.gif
├── STStrava
├── Assets.xcassets
│ ├── Contents.json
│ ├── ConnectWithStrava.imageset
│ │ ├── ConnectWithStrava.png
│ │ ├── ConnectWithStrava@2x.png
│ │ └── Contents.json
│ └── AppIcon.appiconset
│ │ └── Contents.json
├── STAnimatedGIFCreator.swift
├── Info.plist
├── Base.lproj
│ ├── LaunchScreen.storyboard
│ └── Main.storyboard
├── AppDelegate.swift
├── StravaAPI.swift
├── ViewController.swift
├── HTTPRequests.swift
└── ActivitiesChartView.swift
├── README.md
├── STStrava.xcodeproj
├── project.xcworkspace
│ ├── xcuserdata
│ │ └── nst.xcuserdatad
│ │ │ └── UserInterfaceState.xcuserstate
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── STStrava.xcscmblueprint
├── xcuserdata
│ └── nst.xcuserdatad
│ │ ├── xcschemes
│ │ ├── xcschememanagement.plist
│ │ └── STStrava.xcscheme
│ │ └── xcdebugger
│ │ └── Breakpoints_v2.xcbkptlist
└── project.pbxproj
├── athlete.json
└── activities.json
/.gitmodules:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/activities.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nst/STStrava/master/activities.gif
--------------------------------------------------------------------------------
/STStrava/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "version" : 1,
4 | "author" : "xcode"
5 | }
6 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # STStrava
2 | Experimental visualisation of runs using Swift and Strava API
3 |
4 | 
5 |
--------------------------------------------------------------------------------
/STStrava/Assets.xcassets/ConnectWithStrava.imageset/ConnectWithStrava.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nst/STStrava/master/STStrava/Assets.xcassets/ConnectWithStrava.imageset/ConnectWithStrava.png
--------------------------------------------------------------------------------
/STStrava/Assets.xcassets/ConnectWithStrava.imageset/ConnectWithStrava@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nst/STStrava/master/STStrava/Assets.xcassets/ConnectWithStrava.imageset/ConnectWithStrava@2x.png
--------------------------------------------------------------------------------
/STStrava.xcodeproj/project.xcworkspace/xcuserdata/nst.xcuserdatad/UserInterfaceState.xcuserstate:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nst/STStrava/master/STStrava.xcodeproj/project.xcworkspace/xcuserdata/nst.xcuserdatad/UserInterfaceState.xcuserstate
--------------------------------------------------------------------------------
/STStrava.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/STStrava/Assets.xcassets/ConnectWithStrava.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "filename" : "ConnectWithStrava.png",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "filename" : "ConnectWithStrava@2x.png",
11 | "scale" : "2x"
12 | },
13 | {
14 | "idiom" : "universal",
15 | "scale" : "3x"
16 | }
17 | ],
18 | "info" : {
19 | "version" : 1,
20 | "author" : "xcode"
21 | }
22 | }
--------------------------------------------------------------------------------
/STStrava.xcodeproj/xcuserdata/nst.xcuserdatad/xcschemes/xcschememanagement.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SchemeUserState
6 |
7 | STStrava.xcscheme
8 |
9 | orderHint
10 | 0
11 |
12 |
13 | SuppressBuildableAutocreation
14 |
15 | 03B4D5C11BEBFB74009FA801
16 |
17 | primary
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/STStrava/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "size" : "29x29",
6 | "scale" : "2x"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "size" : "29x29",
11 | "scale" : "3x"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "size" : "40x40",
16 | "scale" : "2x"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "size" : "40x40",
21 | "scale" : "3x"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "size" : "60x60",
26 | "scale" : "2x"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "size" : "60x60",
31 | "scale" : "3x"
32 | }
33 | ],
34 | "info" : {
35 | "version" : 1,
36 | "author" : "xcode"
37 | }
38 | }
--------------------------------------------------------------------------------
/STStrava.xcodeproj/xcuserdata/nst.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
8 |
12 |
13 |
14 |
16 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/athlete.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": 11730283,
3 | "username": "nicolas_seriot",
4 | "resource_state": 3,
5 | "firstname": "Nicolas",
6 | "lastname": "Seriot",
7 | "profile_medium": "https:\/\/graph.facebook.com\/v2.1\/10153171840540036\/picture?height=1000",
8 | "profile": "https:\/\/graph.facebook.com\/v2.1\/10153171840540036\/picture?height=1000",
9 | "city": "",
10 | "state": "Vaud",
11 | "country": "Switzerland",
12 | "sex": "M",
13 | "friend": null,
14 | "follower": null,
15 | "premium": false,
16 | "created_at": "2015-10-12T12:40:24Z",
17 | "updated_at": "2015-10-12T20:22:37Z",
18 | "badge_type_id": 0,
19 | "follower_count": 7,
20 | "friend_count": 9,
21 | "mutual_friend_count": 0,
22 | "athlete_type": 1,
23 | "date_preference": "%m\/%d\/%Y",
24 | "measurement_preference": "meters",
25 | "email": "facebook@seriot.ch",
26 | "ftp": null,
27 | "weight": 60,
28 | "clubs": [
29 |
30 | ],
31 | "bikes": [
32 |
33 | ],
34 | "shoes": [
35 |
36 | ]
37 | }
38 |
--------------------------------------------------------------------------------
/STStrava/STAnimatedGIFCreator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // STAnimatedGifCreator.swift
3 | // STStrava
4 | //
5 | // Created by nst on 21/11/15.
6 | // Copyright © 2015 Nicolas Seriot. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import ImageIO
11 | import MobileCoreServices
12 |
13 | open class STAnimatedGIFCreator {
14 |
15 | let imageDestination : CGImageDestination?
16 |
17 | init?(destinationPath: String?, loop: Bool) {
18 |
19 | guard let existingPath = destinationPath else {
20 | return nil
21 | }
22 |
23 | imageDestination = CGImageDestinationCreateWithURL(
24 | URL(fileURLWithPath: existingPath) as CFURL,
25 | kUTTypeGIF,
26 | 0,
27 | nil)
28 |
29 | guard imageDestination != nil else { return nil }
30 |
31 | let loopCount = loop ? 0 : 1
32 | let gifProperties = [ kCGImagePropertyGIFDictionary as String : [kCGImagePropertyGIFLoopCount as String:loopCount] ]
33 |
34 | CGImageDestinationSetProperties(imageDestination!, gifProperties as CFDictionary?)
35 | }
36 |
37 | func addImage(_ image : UIImage, duration : Double) {
38 | let frameProperties : Dictionary = [ kCGImagePropertyGIFDictionary as String : [kCGImagePropertyGIFDelayTime as String:duration] ]
39 | CGImageDestinationAddImage(imageDestination!, image.cgImage!, frameProperties as CFDictionary?)
40 | }
41 |
42 | func writeFile() -> Bool {
43 | return CGImageDestinationFinalize(imageDestination!)
44 | }
45 |
46 | }
47 |
--------------------------------------------------------------------------------
/STStrava/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | APPL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleSignature
20 | ????
21 | CFBundleURLTypes
22 |
23 |
24 | CFBundleTypeRole
25 | Viewer
26 | CFBundleURLName
27 | ch.seriot.STStrava
28 | CFBundleURLSchemes
29 |
30 | STStrava
31 |
32 |
33 |
34 | CFBundleVersion
35 | 1
36 | LSRequiresIPhoneOS
37 |
38 | UILaunchStoryboardName
39 | LaunchScreen
40 | UIMainStoryboardFile
41 | Main
42 | UIRequiredDeviceCapabilities
43 |
44 | armv7
45 |
46 | UISupportedInterfaceOrientations
47 |
48 | UIInterfaceOrientationLandscapeLeft
49 | UIInterfaceOrientationLandscapeRight
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/STStrava/Base.lproj/LaunchScreen.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/STStrava/Base.lproj/Main.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/STStrava.xcodeproj/project.xcworkspace/xcshareddata/STStrava.xcscmblueprint:
--------------------------------------------------------------------------------
1 | {
2 | "DVTSourceControlWorkspaceBlueprintPrimaryRemoteRepositoryKey" : "1E533E32EE941F223B977F26578B8A0B2AAD4754",
3 | "DVTSourceControlWorkspaceBlueprintWorkingCopyRepositoryLocationsKey" : {
4 |
5 | },
6 | "DVTSourceControlWorkspaceBlueprintWorkingCopyStatesKey" : {
7 | "F881BDCD7F90E74E4D29D28458E34DFFDF311661" : 0,
8 | "1E533E32EE941F223B977F26578B8A0B2AAD4754" : 0
9 | },
10 | "DVTSourceControlWorkspaceBlueprintIdentifierKey" : "1B18755D-CD62-448D-B5BC-266952347F9F",
11 | "DVTSourceControlWorkspaceBlueprintWorkingCopyPathsKey" : {
12 | "F881BDCD7F90E74E4D29D28458E34DFFDF311661" : "STStrava\/submodules\/HTTPRequests\/",
13 | "1E533E32EE941F223B977F26578B8A0B2AAD4754" : "STStrava\/"
14 | },
15 | "DVTSourceControlWorkspaceBlueprintNameKey" : "STStrava",
16 | "DVTSourceControlWorkspaceBlueprintVersion" : 204,
17 | "DVTSourceControlWorkspaceBlueprintRelativePathToProjectKey" : "STStrava.xcodeproj",
18 | "DVTSourceControlWorkspaceBlueprintRemoteRepositoriesKey" : [
19 | {
20 | "DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "https:\/\/github.com\/nst\/STStrava",
21 | "DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git",
22 | "DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "1E533E32EE941F223B977F26578B8A0B2AAD4754"
23 | },
24 | {
25 | "DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "https:\/\/github.com\/nst\/HTTPRequests",
26 | "DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git",
27 | "DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "F881BDCD7F90E74E4D29D28458E34DFFDF311661"
28 | }
29 | ]
30 | }
--------------------------------------------------------------------------------
/STStrava/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // STStrava
4 | //
5 | // Created by nst on 05/11/15.
6 | // Copyright © 2015 Nicolas Seriot. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | @UIApplicationMain
12 | class AppDelegate: UIResponder, UIApplicationDelegate {
13 |
14 | var window: UIWindow?
15 |
16 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
17 | // Override point for customization after application launch.
18 | return true
19 | }
20 |
21 | func applicationWillResignActive(_ application: UIApplication) {
22 | // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
23 | // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game.
24 | }
25 |
26 | func applicationDidEnterBackground(_ application: UIApplication) {
27 | // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
28 | // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
29 | }
30 |
31 | func applicationWillEnterForeground(_ application: UIApplication) {
32 | // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background.
33 | }
34 |
35 | func applicationDidBecomeActive(_ application: UIApplication) {
36 | // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
37 | }
38 |
39 | func applicationWillTerminate(_ application: UIApplication) {
40 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
41 | }
42 |
43 | func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool {
44 |
45 | if (url.scheme != "ststrava") {
46 | return false
47 | }
48 |
49 | let queryItems : [URLQueryItem] = URLComponents(url: url, resolvingAgainstBaseURL: false)!.queryItems!
50 |
51 | let codeQueryItem = queryItems.filter{ $0.name == "code" }.first
52 |
53 | if let existingCodeQueryItem = codeQueryItem {
54 | let notification = Notification(name: Notification.Name(rawValue: "CodeWasReceived"), object: nil, userInfo: ["code":existingCodeQueryItem.value!])
55 | NotificationCenter.default.post(notification)
56 | }
57 |
58 | return true
59 | }
60 |
61 | }
62 |
63 |
--------------------------------------------------------------------------------
/STStrava.xcodeproj/xcuserdata/nst.xcuserdatad/xcschemes/STStrava.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
32 |
33 |
39 |
40 |
41 |
42 |
43 |
44 |
54 |
56 |
62 |
63 |
64 |
65 |
66 |
67 |
73 |
75 |
81 |
82 |
83 |
84 |
86 |
87 |
90 |
91 |
92 |
--------------------------------------------------------------------------------
/STStrava/StravaAPI.swift:
--------------------------------------------------------------------------------
1 | //
2 | // STActivities.swift
3 | // STStrava
4 | //
5 | // Created by nst on 12/11/15.
6 | // Copyright © 2015 Nicolas Seriot. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | let USE_LOCAL_FILES : Bool = false
12 |
13 | public enum Result {
14 | case failure(NSError)
15 | case success(T)
16 | }
17 |
18 | func dateFromString(_ dateString: String?) -> Date? {
19 | guard let existringDateString = dateString else { return nil }
20 | let dateFormatter = DateFormatter()
21 | dateFormatter.locale = Locale(identifier: "en_US_POSIX")
22 | dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZ"
23 | return dateFormatter.date(from: existringDateString)
24 | }
25 |
26 | public struct Athlete {
27 | let id: Int
28 | let username: String?
29 | let firstname: String?
30 | let lastname: String?
31 | let profileMedium: String?
32 |
33 | init?(fromAthleteDictionary d: [String:AnyObject]?) {
34 |
35 | guard let d = d else { return nil }
36 | guard let id = d["id"] as? Int else { return nil }
37 |
38 | self.id = id
39 |
40 | username = d["username"] as? String
41 | firstname = d["firstname"] as? String
42 | lastname = d["lastname"] as? String
43 | profileMedium = d["profile_medium"] as? String // TODO: show image
44 | }
45 | }
46 |
47 | public struct Activity {
48 | let id: Int
49 | let date: Date
50 | let meters: Double
51 | let seconds: Int
52 | let elevationGain: Double
53 | let name: String
54 | let type: String
55 | let locationCity: String?
56 | let startDateLocale: Date?
57 | let athlete: Athlete?
58 |
59 | init?(fromActivityDictionary d: [String:AnyObject]?) {
60 |
61 | guard let d = d else { return nil }
62 |
63 | guard
64 | let id = d["id"] as? Int,
65 | let date = dateFromString(d["start_date"] as? String),
66 | let meters = d["distance"] as? Double,
67 | let seconds = d["elapsed_time"] as? Int,
68 | let elevationGain = d["total_elevation_gain"] as? Double,
69 | let name = d["name"] as? String,
70 | let type = d["type"] as? String,
71 | let startDateLocale = dateFromString(d["start_date_local"] as? String),
72 | let athlete = Athlete(fromAthleteDictionary: d["athlete"] as? [String:AnyObject])
73 | else { return nil }
74 |
75 | self.id = id
76 | self.date = date
77 | self.meters = meters
78 | self.seconds = seconds
79 | self.elevationGain = elevationGain
80 | self.name = name
81 | self.type = type
82 | self.locationCity = d["location_city"] as? String
83 | self.startDateLocale = startDateLocale
84 | self.athlete = athlete
85 | }
86 | }
87 |
88 | open class StravaAPI {
89 |
90 | public enum StravaAPI : Error {
91 | case badURL(urlString: String)
92 | case badHTTPStatus(status: Int)
93 | case badJSON
94 | case noData
95 | case stravaErrors(errors: AnyObject?)
96 | case genericError
97 | }
98 |
99 | fileprivate var clientID : String
100 | fileprivate var clientSecret : String
101 | fileprivate(set) var accessToken : String?
102 |
103 | // find clientID and clientSecret on https://www.strava.com/settings/api
104 | public required init(clientID: String, clientSecret: String, storedAccessToken: String?) {
105 | self.clientID = clientID
106 | self.clientSecret = clientSecret
107 | self.accessToken = storedAccessToken
108 |
109 | NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: "CodeWasReceived"), object: nil, queue: nil) { [unowned self] (notification) -> Void in
110 | guard let existingCode = (notification as NSNotification).userInfo?["code"] as? String else { return }
111 |
112 | self.fetchAccessToken(clientID, clientSecret: clientSecret, code: existingCode) { [unowned self] (result) -> () in
113 | switch result {
114 | case let .success(receivedAccessToken):
115 | self.accessToken = receivedAccessToken
116 | print("-- accessToken", receivedAccessToken)
117 | NotificationCenter.default.post(name: Notification.Name(rawValue: "StravaAPIHasAccessToken"), object: nil, userInfo: ["accessToken":receivedAccessToken])
118 | case let .failure(error):
119 | print(error)
120 | }
121 | }
122 | }
123 | }
124 |
125 | public convenience init(clientID: String, clientSecret: String) {
126 | self.init(clientID: clientID, clientSecret: clientSecret, storedAccessToken: nil)
127 | }
128 |
129 | deinit {
130 | NotificationCenter.default.removeObserver(self, name: NSNotification.Name(rawValue: "CodeWasReceived"), object: nil)
131 | }
132 |
133 | open func fetchAthlete(_ completionHandler: @escaping (Result) -> ()) {
134 |
135 | let urlString = "https://www.strava.com/api/v3/athlete" + accessTokenURLSuffix()
136 | let url = URL(string: urlString)
137 | let request = URLRequest(url: url!)
138 |
139 | request.dr2_fetchTypedJSON([String:AnyObject].self) {
140 | do {
141 | let (_, d) = try $0()
142 | if let errors = d["errors"] {
143 | let e = NSError(domain: "STStrava", code: StravaAPI.badJSON._code, userInfo: [NSLocalizedDescriptionKey:String(describing:errors)])
144 | completionHandler(.failure(e))
145 | return
146 | }
147 |
148 | if let athlete = Athlete(fromAthleteDictionary: d) {
149 | completionHandler(.success(athlete))
150 | } else {
151 | let e = NSError(domain: "STStrava", code: StravaAPI.badJSON._code, userInfo: [NSLocalizedDescriptionKey:"Bad JSON"])
152 | completionHandler(.failure(e))
153 | }
154 | } catch let e as NSError {
155 | completionHandler(.failure(e))
156 | }
157 | }
158 | }
159 |
160 | open func fetchActivities(_ completionHandler: @escaping (Result<[Activity]>) -> ()) {
161 |
162 | let urlString = "https://www.strava.com/api/v3/activities" + accessTokenURLSuffix() + "&per_page=200"
163 | let url = URL(string: urlString)
164 | let request = URLRequest(url: url!)
165 |
166 | request.dr2_fetchTypedJSON([[String:AnyObject]].self) {
167 | do {
168 | let (_, a) = try $0()
169 | let runActivities = self.sortedRunActivitiesFromJSONArray(a)
170 | completionHandler(.success(runActivities))
171 | } catch let DRError.error(r, nsError) {
172 |
173 | // try to read a Strava error
174 |
175 | if let data = r.data,
176 | let optDict = try? JSONSerialization.jsonObject(with: data, options: []) as? [String:AnyObject],
177 | let d = optDict,
178 | let message = d["message"] as? String,
179 | let errors = d["errors"] as? [[String:AnyObject]] {
180 | print("-- ", d)
181 |
182 | // build a custom NSError
183 | let userInfo = [NSLocalizedDescriptionKey:"\(message) - \(errors)"]
184 | let e = NSError(domain: "Strava", code: 0, userInfo: userInfo)
185 | completionHandler(.failure(e))
186 | return
187 | }
188 |
189 | completionHandler(.failure(nsError))
190 | } catch {
191 | assertionFailure()
192 | }
193 | }
194 | }
195 |
196 | open func hasAccessToken() -> Bool {
197 | return self.accessToken != nil
198 | }
199 |
200 | open func forgetAccessToken() {
201 | self.accessToken = nil
202 | }
203 |
204 | open func startAuthorizationProcess(redirectURI: String) {
205 |
206 | let urlString = "https://www.strava.com/oauth/authorize?client_id=\(clientID)&response_type=code&redirect_uri=\(redirectURI)&approval_prompt=force"
207 | let url = URL(string: urlString)
208 |
209 | if let existingURL = url {
210 | // notification instead of direct opening so that this class doesn't depend on UIKit
211 | NotificationCenter.default.post(name: Notification.Name(rawValue: "OpenURL"), object: nil, userInfo: ["URL":existingURL])
212 | }
213 | }
214 |
215 | fileprivate func fetchAccessToken(_ clientID: String, clientSecret: String, code: String, completionBlock:@escaping (Result) -> ()) {
216 |
217 | /*
218 | curl -X POST https://www.strava.com/oauth/token \
219 | -F client_id=111 \
220 | -F client_secret=222 \
221 | -F code=333
222 | */
223 |
224 | let url = URL(string: "https://www.strava.com/oauth/token")!
225 | var request = URLRequest(url: url)
226 | request.httpMethod = "POST"
227 | let body = "client_id=\(clientID)&client_secret=\(clientSecret)&code=\(code)"
228 | request.httpBody = body.data(using: String.Encoding.utf8);
229 |
230 | request.dr2_fetchTypedJSON([String:AnyObject].self) {
231 | do {
232 | let (_, d) = try $0()
233 |
234 | guard let receivedExistingAccessToken = d["access_token"] as? String else {
235 | let e = NSError(domain: "STStrava", code: StravaAPI.badJSON._code, userInfo: [NSLocalizedDescriptionKey:"Bad JSON"])
236 | completionBlock(.failure(e))
237 | return
238 | }
239 | completionBlock(.success(receivedExistingAccessToken))
240 | } catch let e as NSError {
241 | completionBlock(.failure(e))
242 | }
243 | }
244 | }
245 |
246 | fileprivate func accessTokenURLSuffix() -> String {
247 | if let existingAccessToken = self.accessToken {
248 | return "?access_token=" + existingAccessToken
249 | }
250 | return ""
251 | }
252 |
253 | fileprivate func sortedRunActivitiesFromJSONArray(_ a:[[String:AnyObject]]) -> [Activity] {
254 | return a
255 | .flatMap( { $0 })
256 | .flatMap( { Activity(fromActivityDictionary:$0) })
257 | .filter{ $0.type == "Run" }
258 | .sorted(by: { $0.date.compare($1.date) == ComparisonResult.orderedAscending })
259 | }
260 |
261 | open func fetchFriendsActivities(_ athleteID: Int, completionHandler: @escaping (Result<[Activity]>) -> ()) {
262 |
263 | let urlString = "https://www.strava.com/api/v3/activities/following" + accessTokenURLSuffix()
264 | let url = URL(string: urlString)
265 | let request = URLRequest(url: url!)
266 |
267 | request.dr2_fetchTypedJSON([[String:AnyObject]].self) {
268 | do {
269 | let (_, a) = try $0()
270 | let runActivitiesFromSpecificFriend = self.sortedRunActivitiesFromJSONArray(a).filter{ $0.athlete?.id == athleteID }
271 | completionHandler(.success(runActivitiesFromSpecificFriend))
272 | } catch let e as NSError {
273 | completionHandler(.failure(e))
274 | }
275 | }
276 | }
277 |
278 | fileprivate func fetchActivity(_ activityID: String, completionHandler: @escaping (Result) -> ()) {
279 |
280 | let urlString = "https://www.strava.com/api/v3/activities/\(activityID)" + accessTokenURLSuffix()
281 | let url = URL(string: urlString)
282 | let request = URLRequest(url: url!)
283 |
284 | request.dr2_fetchTypedJSON([String:AnyObject].self) {
285 | do {
286 | let (_, d) = try $0()
287 |
288 | if let activity = Activity(fromActivityDictionary: d) {
289 | completionHandler(.success(activity))
290 | } else {
291 | let e = NSError(domain: "STStrava", code: StravaAPI.badJSON._code, userInfo: [NSLocalizedDescriptionKey:"Bad JSON"])
292 | completionHandler(.failure(e))
293 | }
294 |
295 | } catch let e as NSError {
296 | completionHandler(.failure(e))
297 | }
298 | }
299 | }
300 |
301 | fileprivate func maxElevationGain(_ shortActivities:[Activity]) -> NSNumber {
302 | let x = shortActivities.min(by: { $0.elevationGain > $1.elevationGain })!.elevationGain
303 | return NSNumber(value:x)
304 | }
305 |
306 | fileprivate func maxMeters(_ shortActivities:[Activity]) -> NSNumber {
307 | let x = shortActivities.min(by: { $0.meters > $1.meters })!.meters
308 | return NSNumber(value:x)
309 | }
310 |
311 | fileprivate func maxSeconds(_ shortActivities:[Activity]) -> NSNumber {
312 | let x = shortActivities.min(by: { $0.seconds > $1.seconds })!.seconds
313 | return NSNumber(value:x)
314 | }
315 | }
316 |
--------------------------------------------------------------------------------
/STStrava/ViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ViewController.swift
3 | // STStrava
4 | //
5 | // Created by nst on 05/11/15.
6 | // Copyright © 2015 Nicolas Seriot. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import MessageUI
11 |
12 | class ViewController: UIViewController, MFMailComposeViewControllerDelegate {
13 |
14 | @IBOutlet var connectButton : UIButton!
15 | @IBOutlet var disconnectButton : UIButton!
16 | @IBOutlet var fetchDataButton : UIButton!
17 | @IBOutlet var displayDataButton : UIButton!
18 | @IBOutlet var createGIFButton : UIButton!
19 | @IBOutlet var statusLabel : UILabel!
20 |
21 | fileprivate var athlete: Athlete?
22 | fileprivate var activities: [Activity]?
23 |
24 | # enter your client ID and client secret below
25 | fileprivate let stravaAPI : StravaAPI = {
26 | let storedAccessToken = UserDefaults.standard.value(forKey: "StravaAccessToken") as? String
27 | return StravaAPI(
28 | clientID: "",
29 | clientSecret: "",
30 | storedAccessToken: storedAccessToken)
31 | }()
32 |
33 | override func viewDidLoad() {
34 | super.viewDidLoad()
35 |
36 | // subviews
37 |
38 | self.connectButton = UIButton(type: UIButtonType.custom)
39 | let connectImage = UIImage(named: "ConnectWithStrava")
40 | self.connectButton.setImage(connectImage, for: UIControlState())
41 | self.connectButton.addTarget(self, action:#selector(ViewController.connectButtonClicked(_:)), for: UIControlEvents.touchUpInside)
42 | self.connectButton.translatesAutoresizingMaskIntoConstraints = false
43 | self.view.addSubview(connectButton!)
44 |
45 | self.disconnectButton = UIButton(type: UIButtonType.system)
46 | self.disconnectButton.setTitle("Disconnect", for: UIControlState())
47 | self.disconnectButton.addTarget(self, action:#selector(ViewController.disconnectButtonClicked(_:)), for: UIControlEvents.touchUpInside)
48 | self.disconnectButton.translatesAutoresizingMaskIntoConstraints = false
49 | self.view.addSubview(disconnectButton!)
50 |
51 | self.fetchDataButton = UIButton(type: UIButtonType.system)
52 | self.fetchDataButton.setTitle("Fetch Data", for: UIControlState())
53 | self.fetchDataButton.addTarget(self, action:#selector(ViewController.fetchData(_:)), for: UIControlEvents.touchUpInside)
54 | self.fetchDataButton.translatesAutoresizingMaskIntoConstraints = false
55 | self.view.addSubview(fetchDataButton!)
56 |
57 | self.displayDataButton = UIButton(type: UIButtonType.system)
58 | self.displayDataButton.setTitle("Display Data", for: UIControlState())
59 | self.displayDataButton.addTarget(self, action:#selector(ViewController.displayData(_:)), for: UIControlEvents.touchUpInside)
60 | self.displayDataButton.translatesAutoresizingMaskIntoConstraints = false
61 | self.view.addSubview(displayDataButton!)
62 |
63 | self.createGIFButton = UIButton(type: UIButtonType.system)
64 | self.createGIFButton.setTitle("Send GIF by Email", for: UIControlState())
65 | self.createGIFButton.addTarget(self, action:#selector(ViewController.createAndSendGIF(_:)), for: UIControlEvents.touchUpInside)
66 | self.createGIFButton.translatesAutoresizingMaskIntoConstraints = false
67 | self.view.addSubview(createGIFButton!)
68 |
69 | self.statusLabel = UILabel()
70 | self.statusLabel.textAlignment = .center
71 | self.statusLabel.translatesAutoresizingMaskIntoConstraints = false
72 | self.view.addSubview(statusLabel)
73 |
74 | // autolayout
75 |
76 | let views = ["connectButton":connectButton, "statusLabel":statusLabel, "disconnectButton":disconnectButton, "fetchDataButton":fetchDataButton, "displayDataButton":displayDataButton, "createGIFButton":createGIFButton] as [String : Any]
77 | self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|-20-[connectButton]-20-[statusLabel]-20-[disconnectButton]-20-[fetchDataButton]-20-[displayDataButton]-20-[createGIFButton]", options:NSLayoutFormatOptions(), metrics:nil, views:views))
78 | self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|-[connectButton]-|", options:.alignAllCenterX, metrics:nil, views:views))
79 | self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|-[statusLabel]-|", options:.alignAllCenterX, metrics:nil, views:views))
80 | self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|-[disconnectButton]-|", options:.alignAllCenterX, metrics:nil, views:views))
81 | self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|-[fetchDataButton]-|", options:.alignAllCenterX, metrics:nil, views:views))
82 | self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|-[displayDataButton]-|", options:.alignAllCenterX, metrics:nil, views:views))
83 | self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|-[createGIFButton]-|", options:.alignAllCenterX, metrics:nil, views:views))
84 |
85 | self.updateDisplayAccordingToStravaAPIConnection()
86 |
87 | // notifications
88 |
89 | NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: "OpenURL"), object: nil, queue: nil) { (notification) -> Void in
90 | if let existingURL = (notification as NSNotification).userInfo?["URL"] as? URL {
91 | UIApplication.shared.openURL(existingURL)
92 | }
93 | }
94 |
95 | NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: "StravaAPIHasAccessToken"), object: nil, queue: nil) { [unowned self] (notification) -> Void in
96 | print("-- StravaAPIHasAccessToken")
97 |
98 | if let accessToken = (notification as NSNotification).userInfo?["accessToken"] as? String {
99 | UserDefaults.standard.set(accessToken, forKey:"StravaAccessToken")
100 | }
101 |
102 | self.updateDisplayAccordingToStravaAPIConnection()
103 | }
104 | }
105 |
106 | func updateDisplayAccordingToStravaAPIConnection() {
107 |
108 | let accessToken = self.stravaAPI.accessToken
109 |
110 | let isConnected = accessToken != nil
111 |
112 | self.connectButton.isHidden = isConnected
113 |
114 | let status = isConnected ? "Connected with token: \(accessToken!)" : ""
115 |
116 | self.statusLabel.text = status
117 |
118 | self.disconnectButton.isHidden = !isConnected
119 |
120 | self.fetchDataButton.isHidden = !isConnected
121 |
122 | self.displayDataButton.isHidden = self.athlete == nil || self.activities == nil
123 |
124 | self.createGIFButton.isHidden = self.displayDataButton.isHidden
125 | }
126 |
127 | deinit {
128 | NotificationCenter.default.removeObserver(self, name: NSNotification.Name(rawValue: "OpenURL"), object: nil)
129 | NotificationCenter.default.removeObserver(self, name: NSNotification.Name(rawValue: "StravaAPIHasAccessToken"), object: nil)
130 | }
131 |
132 | override func didReceiveMemoryWarning() {
133 | super.didReceiveMemoryWarning()
134 | // Dispose of any resources that can be recreated.
135 | }
136 |
137 | func createAnimatedGIF(_ completionHandler:(_ gifPath:String?) -> ()) {
138 |
139 | let path = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
140 | let pathURL = URL(fileURLWithPath: path)
141 | let fileURL = pathURL.appendingPathComponent("activities.gif")
142 | let fileURLPath = fileURL.path
143 |
144 | let gifCreator = STAnimatedGIFCreator(destinationPath: fileURLPath, loop: false)
145 | guard let existingGIFCreator = gifCreator else { return }
146 |
147 | guard let existingAthlete = self.athlete else { return }
148 | guard let existingActivities = self.activities else { return }
149 |
150 | var localActivities = existingActivities
151 |
152 | for i in 0...localActivities.count {
153 |
154 | let chartView = ActivitiesChartView(frame: self.view.frame)
155 | chartView.setupGlobalStatsFromActivities(localActivities)
156 | let subActivities = Array(localActivities[0.. Void in
181 | }
182 |
183 | let disconnectAction = UIAlertAction(title: "Disconnect", style: .destructive) { action -> Void in
184 | self.stravaAPI.forgetAccessToken()
185 | self.updateDisplayAccordingToStravaAPIConnection()
186 | }
187 |
188 | actionSheetController.addAction(cancelAction)
189 | actionSheetController.addAction(disconnectAction)
190 |
191 | actionSheetController.popoverPresentationController?.sourceView = sender as UIView
192 |
193 | self.present(actionSheetController, animated: true, completion: nil)
194 | }
195 |
196 | @IBAction func fetchData(_ sender: UIButton!) {
197 |
198 | self.statusLabel.text = ""
199 |
200 | self.stravaAPI.fetchAthlete { [unowned self] (result) -> () in
201 |
202 | switch result {
203 | case let .success(athlete):
204 |
205 | self.athlete = athlete
206 |
207 | self.stravaAPI.fetchActivities() { [unowned self] (result) -> () in
208 |
209 | switch result {
210 | case let .success(activities):
211 |
212 | self.activities = activities
213 |
214 | self.updateDisplayAccordingToStravaAPIConnection()
215 |
216 | case let .failure(error):
217 |
218 | self.statusLabel.text = error.localizedDescription
219 | }
220 | }
221 |
222 | case let .failure(error):
223 | self.statusLabel.text = error.localizedDescription
224 | }
225 | }
226 | }
227 |
228 | @IBAction func displayData(_ sender: UIButton!) {
229 | let chartView = ActivitiesChartView(frame: self.view.frame)
230 | chartView.setData(self.athlete!, activities: self.activities!) // TODO: don't force unwrap
231 |
232 | UIGraphicsBeginImageContextWithOptions(view.bounds.size, true, 0)
233 | chartView.drawHierarchy(in: view.bounds, afterScreenUpdates: true)
234 | let image = UIGraphicsGetImageFromCurrentImageContext()
235 | UIGraphicsEndImageContext()
236 |
237 | let imageView = UIImageView(frame: self.view.frame)
238 | imageView.image = image
239 | self.view.addSubview(imageView)
240 |
241 | let tap = UITapGestureRecognizer(target:self, action:#selector(ViewController.imageViewTapped(_:)))
242 | imageView.addGestureRecognizer(tap)
243 | imageView.isUserInteractionEnabled = true
244 | }
245 |
246 | @IBAction func imageViewTapped(_ gestureRecognizer: UITapGestureRecognizer) {
247 | print("imageViewTapped:")
248 |
249 | if let view = gestureRecognizer.view {
250 | view.removeFromSuperview()
251 | }
252 | }
253 |
254 | @IBAction func createAndSendGIF(_ sender: UIButton!) {
255 | self.createAnimatedGIF({ [unowned self] (gifPath) -> () in
256 |
257 | if let existingGifPath = gifPath {
258 | print("--", existingGifPath)
259 |
260 | self.sendGIFByEmail(existingGifPath)
261 | }
262 | })
263 | }
264 |
265 | func sendGIFByEmail(_ path:String) {
266 | let data = try? Data(contentsOf: URL(fileURLWithPath: path))
267 | guard let existingData = data else {
268 | return
269 | }
270 |
271 | var subject = "STStrava Animated GIF"
272 | if let athleteUsername = athlete?.username {
273 | subject += " for \(athleteUsername)"
274 | }
275 |
276 | let mc = MFMailComposeViewController()
277 | mc.mailComposeDelegate = self
278 | mc.setSubject(subject)
279 | mc.setMessageBody("", isHTML: false)
280 | mc.addAttachmentData(existingData, mimeType: "image/gif", fileName: (path as NSString).lastPathComponent)
281 |
282 | self.present(mc, animated: true, completion: {})
283 | }
284 |
285 | func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
286 | /*
287 | switch result.rawValue {
288 | case MFMailComposeResultCancelled.rawValue:
289 | print("Mail cancelled")
290 | case MFMailComposeResultSaved.rawValue:
291 | print("Mail saved")
292 | case MFMailComposeResultSent.rawValue:
293 | print("Mail sent")
294 | case MFMailComposeResultFailed.rawValue:
295 | print("Mail sent failure: \(error!.localizedDescription)")
296 | default:
297 | break
298 | }
299 | controller.dismissViewControllerAnimated(true, completion: nil)
300 | */
301 | }
302 | }
303 |
--------------------------------------------------------------------------------
/STStrava/HTTPRequests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HTTPRequests.swift
3 | // HTTPRequests
4 | //
5 | // Created by Nicolas Seriot on 30/03/16.
6 | // Copyright © 2016 Nicolas Seriot. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension Error {
12 | func nsError(_ localizedDescription:String?, underlyingError:NSError? = nil) -> NSError {
13 | var userInfo : [AnyHashable: Any] = [:]
14 | if let s = localizedDescription {
15 | userInfo[NSLocalizedDescriptionKey] = s
16 | }
17 | if let u = underlyingError {
18 | userInfo[NSUnderlyingErrorKey] = u
19 | }
20 | return NSError(domain: self._domain, code: self._code, userInfo: userInfo)
21 | }
22 | }
23 |
24 | open class HTTPResponse : NSObject {
25 |
26 | public enum HTTPResponse: Error {
27 | case noData
28 | case unexpectedJSONType
29 | }
30 |
31 | open var status : Int = 0
32 | open var headers : [AnyHashable: Any] = [:]
33 | open var data : Data? = nil
34 |
35 | override public init() {
36 | super.init()
37 | }
38 |
39 | public init(status:Int, headers:[AnyHashable: Any], data:Data) {
40 | super.init()
41 | self.status = status
42 | self.headers = headers
43 | self.data = data
44 | }
45 |
46 | open func json(_ type:T.Type) throws -> T {
47 | guard let existingData = self.data else {
48 | throw HTTPResponse.noData.nsError("No Data")
49 | }
50 |
51 | let json = try JSONSerialization.jsonObject(with: existingData, options: [])
52 |
53 | guard let typedJSON = json as? T else {
54 | throw HTTPResponse.unexpectedJSONType.nsError("Expected JSON type: \(type(of: type)), found: \(type(of: json))")
55 | }
56 |
57 | return typedJSON
58 | }
59 | }
60 |
61 | public enum HTTPResultType {
62 | case success(httpResponse:HTTPResponse, value:T)
63 | case failure(httpResponse:HTTPResponse, nsError:NSError)
64 | }
65 |
66 | public enum DRError : Error {
67 | case error(httpResponse:HTTPResponse, nsError:NSError)
68 | }
69 |
70 | // 1. sum type
71 | extension URLRequest {
72 |
73 | public func st_fetchData(_ completionHandler:@escaping (HTTPResultType)->()) {
74 |
75 | #if DEBUG
76 | print(self.curlDescription())
77 | #endif
78 |
79 | URLSession.shared.dataTask(with: self, completionHandler: { (optionalData, optionalResponse, optionalError) -> Void in
80 |
81 | DispatchQueue.main.async(execute: {
82 |
83 | guard let data = optionalData else {
84 | guard let e = optionalError else { assertionFailure(); return }
85 | completionHandler(.failure(httpResponse: HTTPResponse(), nsError: e as NSError))
86 | return
87 | }
88 |
89 | guard let httpResponse = optionalResponse as? HTTPURLResponse else {
90 | guard let e = optionalError else { assertionFailure(); return }
91 | completionHandler(.failure(httpResponse: HTTPResponse(), nsError: e as NSError))
92 | return
93 | }
94 |
95 | let response = HTTPResponse(
96 | status:httpResponse.statusCode,
97 | headers:httpResponse.allHeaderFields,
98 | data:data)
99 |
100 | completionHandler(.success(httpResponse:response, value:data))
101 | })
102 | }) .resume()
103 | }
104 |
105 | public func st_fetchJSON(_ completionHandler:@escaping (HTTPResultType)->()) {
106 | st_fetchTypedJSON(AnyObject.self) {
107 | completionHandler($0)
108 | }
109 | }
110 |
111 | public func st_fetchTypedJSON(_ type:T.Type, completionHandler:@escaping (HTTPResultType) -> ()) {
112 | st_fetchData { (result) -> () in
113 |
114 | switch(result) {
115 | case let .failure(httpResponse, nsError):
116 | completionHandler(.failure(httpResponse: httpResponse, nsError: nsError))
117 | case let .success(httpResponse, _):
118 | do {
119 | completionHandler(.success(httpResponse: httpResponse, value: try httpResponse.json(T.self)))
120 | } catch let e as NSError {
121 | completionHandler(.failure(httpResponse: httpResponse, nsError: e))
122 | }
123 | }
124 | }
125 | }
126 | }
127 |
128 | // 2. deferred result
129 | extension URLRequest {
130 |
131 | public func dr_fetchData(_ completion:@escaping (_ result: () throws -> HTTPResponse) -> () ) {
132 |
133 | #if DEBUG
134 | print(self.curlDescription())
135 | #endif
136 |
137 | URLSession.shared.dataTask(with: self, completionHandler: { (optionalData, optionalResponse, optionalError) -> Void in
138 |
139 | DispatchQueue.main.async(execute: {
140 |
141 | guard let data = optionalData else {
142 | guard let e = optionalError else { assertionFailure(); return }
143 | completion({ throw e } )
144 | return
145 | }
146 |
147 | guard let httpResponse = optionalResponse as? HTTPURLResponse else {
148 | guard let e = optionalError else { assertionFailure(); return }
149 | completion({ throw e } )
150 | return
151 | }
152 |
153 | let response = HTTPResponse(
154 | status:httpResponse.statusCode,
155 | headers:httpResponse.allHeaderFields,
156 | data:data)
157 |
158 | completion({ return response })
159 | })
160 | }) .resume()
161 | }
162 |
163 | public func dr_fetchJSON(_ completion:@escaping (_ result: () throws -> (httpResponse:HTTPResponse, json:AnyObject)) -> () ) {
164 | dr_fetchTypedJSON(AnyObject.self, completion: completion)
165 | }
166 |
167 | public func dr_fetchTypedJSON(_ type:T.Type, completion:@escaping (_ result: () throws -> (httpResponse:HTTPResponse, json:T)) -> () ) {
168 | dr_fetchData {
169 | do {
170 | let httpResponse = try $0()
171 | completion({ return (httpResponse:httpResponse, json:try httpResponse.json(T.self)) } )
172 | } catch let e as NSError {
173 | completion({ throw e } )
174 | }
175 | }
176 | }
177 | }
178 |
179 | // 3. deferred result and DRError
180 | extension URLRequest {
181 |
182 | public func dr2_fetchData(_ completion:@escaping (_ result: () throws -> HTTPResponse) -> () ) {
183 |
184 | #if DEBUG
185 | print(self.curlDescription())
186 | #endif
187 |
188 | URLSession.shared.dataTask(with: self, completionHandler: { (optionalData, optionalResponse, optionalError) -> Void in
189 |
190 | DispatchQueue.main.async(execute: {
191 |
192 | guard let data = optionalData else {
193 | guard let e = optionalError else { assertionFailure(); return }
194 | completion({ throw DRError.error(httpResponse:HTTPResponse(), nsError:e as NSError) } )
195 | return
196 | }
197 |
198 | guard let httpResponse = optionalResponse as? HTTPURLResponse else {
199 | guard let e = optionalError else { assertionFailure(); return }
200 | completion({ throw DRError.error(httpResponse:HTTPResponse(), nsError:e as NSError) } )
201 | return
202 | }
203 |
204 | let response = HTTPResponse(
205 | status:httpResponse.statusCode,
206 | headers:httpResponse.allHeaderFields,
207 | data:data)
208 |
209 | completion({ return response })
210 | })
211 | }) .resume()
212 | }
213 |
214 | public func dr2_fetchJSON(_ completion:@escaping (_ result: () throws -> (httpResponse:HTTPResponse, json:AnyObject)) -> () ) {
215 | dr2_fetchTypedJSON(AnyObject.self, completion: completion)
216 | }
217 |
218 | public func dr2_fetchTypedJSON(_ type:T.Type, completion:@escaping (_ result: () throws -> (httpResponse:HTTPResponse, json:T)) -> () ) {
219 | dr2_fetchData {
220 | do {
221 | let httpResponse = try $0()
222 | do {
223 | let json = try httpResponse.json(T.self)
224 | completion({ return (httpResponse:httpResponse, json:json) } )
225 | } catch let nsError as NSError { // JSON error
226 | let dre = DRError.error(httpResponse:httpResponse, nsError:nsError)
227 | completion({ throw dre } )
228 | } catch {
229 | completion({ throw error } )
230 | }
231 | } catch let dre as DRError {
232 | completion({ throw dre } )
233 | } catch let nsError as NSError {
234 | completion({ throw DRError.error(httpResponse:HTTPResponse(), nsError:nsError) } )
235 | }
236 | }
237 | }
238 | }
239 |
240 | // 4. success and error closures
241 | extension URLRequest {
242 |
243 |
244 | public func se_fetchData(successHandler:@escaping (HTTPResponse)->(), errorHandler:@escaping (NSError)->()) {
245 |
246 | #if DEBUG
247 | print(self.curlDescription())
248 | #endif
249 |
250 | URLSession.shared.dataTask(with: self, completionHandler: { (optionalData, optionalResponse, optionalError) -> Void in
251 |
252 | DispatchQueue.main.async(execute: {
253 |
254 | guard let data = optionalData else {
255 | guard let e = optionalError else { assertionFailure(); return }
256 | errorHandler(e as NSError)
257 | return
258 | }
259 |
260 | guard let httpResponse = optionalResponse as? HTTPURLResponse else {
261 | guard let e = optionalError else { assertionFailure(); return }
262 | errorHandler(e as NSError)
263 | return
264 | }
265 |
266 | let response = HTTPResponse(
267 | status:httpResponse.statusCode,
268 | headers:httpResponse.allHeaderFields,
269 | data:data)
270 |
271 | successHandler(response)
272 | })
273 | }) .resume()
274 | }
275 |
276 |
277 | public func se_fetchJSON(
278 | successHandler:@escaping (HTTPResponse, AnyObject)->(),
279 | errorHandler:@escaping (HTTPResponse, NSError)->()) {
280 |
281 | se_fetchTypedJSON(successHandler: { (httpResponse, json:AnyObject) in
282 | successHandler(httpResponse, json)
283 | }, errorHandler: { (httpResponse, error) in
284 | errorHandler(httpResponse, error)
285 | })
286 | }
287 |
288 | public func se_fetchTypedJSON(
289 | successHandler:@escaping (HTTPResponse, T)->(),
290 | errorHandler:@escaping (HTTPResponse, NSError)->()) {
291 |
292 | se_fetchData(
293 | successHandler:{ (httpResponse) in
294 | do {
295 | let json = try httpResponse.json(T.self)
296 | successHandler(httpResponse, json)
297 | } catch let e as NSError {
298 | errorHandler(httpResponse, e)
299 | }
300 | }, errorHandler:{ (nsError) in
301 | errorHandler(HTTPResponse(), nsError)
302 | })
303 | }
304 |
305 | }
306 |
307 | // cURL description
308 | extension URLRequest {
309 |
310 | public func curlDescription() -> String {
311 |
312 | var s = "\u{0001F340} curl -i \\\n"
313 |
314 | if let
315 | credential = self.requestCredential(),
316 | let user = credential.user,
317 | let password = credential.password
318 | {
319 | s += "-u \(user):\(password) \\\n"
320 | }
321 |
322 | if let method = self.httpMethod , method != "GET" {
323 | s += "-X \(method) \\\n"
324 | }
325 |
326 | self.allHTTPHeaderFields?.forEach({ (k,v) -> () in
327 | let kEscaped = k.replacingOccurrences(of: "\"", with: "\\\"")
328 | let vEscaped = v.replacingOccurrences(of: "\"", with: "\\\"")
329 | s += "-H \"\(kEscaped): \(vEscaped)\" \\\n"
330 | })
331 |
332 | if let url = self.url {
333 | if let cookies = HTTPCookieStorage.shared.cookies(for: url) {
334 | for (_,v) in HTTPCookie.requestHeaderFields(with: cookies) {
335 | s += "-H \"Cookie: \(v)\" \\\n"
336 | }
337 | }
338 | }
339 |
340 | if let bodyData = self.httpBody,
341 | let bodyString = NSString(data: bodyData, encoding: String.Encoding.utf8.rawValue) as? String {
342 | let bodyEscaped = bodyString.replacingOccurrences(of: "\"", with: "\\\"")
343 | s += "-d \"\(bodyEscaped)\" \\\n"
344 | }
345 |
346 | if let url = self.url {
347 | s += "\"\(url.absoluteString)\"\n"
348 | }
349 |
350 | return s
351 | }
352 |
353 | fileprivate func requestCredential() -> URLCredential? {
354 |
355 | guard let url = self.url else { return nil }
356 | guard let host = url.host else { return nil }
357 |
358 | let credentialsDictionary = URLCredentialStorage.shared.allCredentials
359 |
360 | for protectionSpace in credentialsDictionary.keys {
361 |
362 | if let c = credentialsDictionary.values.first?.values.first ,
363 | // we consider neither realm nor host, NSURL instance doesn't know them in advance
364 | (host as NSString).hasSuffix(protectionSpace.host) &&
365 | protectionSpace.`protocol` == url.scheme {
366 | return c
367 | }
368 | }
369 |
370 | return nil
371 | }
372 | }
373 |
--------------------------------------------------------------------------------
/STStrava.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 46;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | 03449C6A1BF4F3EB0037BC6D /* ActivitiesChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03449C691BF4F3EB0037BC6D /* ActivitiesChartView.swift */; };
11 | 03693EBA1C14F52600E05BA8 /* activities.gif in Resources */ = {isa = PBXBuildFile; fileRef = 03693EB91C14F52600E05BA8 /* activities.gif */; };
12 | 03979F121BF9255200C6DF09 /* activities.json in Resources */ = {isa = PBXBuildFile; fileRef = 03979F111BF9255200C6DF09 /* activities.json */; };
13 | 03979F1E1C010E3E00C6DF09 /* STAnimatedGIFCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03979F1D1C010E3E00C6DF09 /* STAnimatedGIFCreator.swift */; };
14 | 03B4D5C61BEBFB74009FA801 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B4D5C51BEBFB74009FA801 /* AppDelegate.swift */; };
15 | 03B4D5C81BEBFB74009FA801 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03B4D5C71BEBFB74009FA801 /* ViewController.swift */; };
16 | 03B4D5CB1BEBFB74009FA801 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 03B4D5C91BEBFB74009FA801 /* Main.storyboard */; };
17 | 03B4D5CD1BEBFB74009FA801 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 03B4D5CC1BEBFB74009FA801 /* Assets.xcassets */; };
18 | 03B4D5D01BEBFB74009FA801 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 03B4D5CE1BEBFB74009FA801 /* LaunchScreen.storyboard */; };
19 | 03BFB0141C13A9B7003FD9B1 /* athlete.json in Resources */ = {isa = PBXBuildFile; fileRef = 03BFB0131C13A9B7003FD9B1 /* athlete.json */; };
20 | 03C316F31DD335B9001B03F8 /* HTTPRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C316F21DD335B9001B03F8 /* HTTPRequests.swift */; };
21 | 03C776261BF525DA00A135BA /* StravaAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C776251BF525DA00A135BA /* StravaAPI.swift */; };
22 | /* End PBXBuildFile section */
23 |
24 | /* Begin PBXFileReference section */
25 | 033E87171CD886B3009DD86A /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; };
26 | 03449C691BF4F3EB0037BC6D /* ActivitiesChartView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActivitiesChartView.swift; sourceTree = ""; };
27 | 03693EB91C14F52600E05BA8 /* activities.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = activities.gif; sourceTree = ""; };
28 | 03979F111BF9255200C6DF09 /* activities.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = activities.json; sourceTree = ""; };
29 | 03979F1D1C010E3E00C6DF09 /* STAnimatedGIFCreator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = STAnimatedGIFCreator.swift; path = STStrava/STAnimatedGIFCreator.swift; sourceTree = ""; };
30 | 03B4D5C21BEBFB74009FA801 /* STStrava.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = STStrava.app; sourceTree = BUILT_PRODUCTS_DIR; };
31 | 03B4D5C51BEBFB74009FA801 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
32 | 03B4D5C71BEBFB74009FA801 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; };
33 | 03B4D5CA1BEBFB74009FA801 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
34 | 03B4D5CC1BEBFB74009FA801 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
35 | 03B4D5CF1BEBFB74009FA801 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
36 | 03B4D5D11BEBFB74009FA801 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
37 | 03BFB0131C13A9B7003FD9B1 /* athlete.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = athlete.json; sourceTree = ""; };
38 | 03C316F21DD335B9001B03F8 /* HTTPRequests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = HTTPRequests.swift; path = STStrava/HTTPRequests.swift; sourceTree = ""; };
39 | 03C776251BF525DA00A135BA /* StravaAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StravaAPI.swift; sourceTree = ""; };
40 | /* End PBXFileReference section */
41 |
42 | /* Begin PBXFrameworksBuildPhase section */
43 | 03B4D5BF1BEBFB74009FA801 /* Frameworks */ = {
44 | isa = PBXFrameworksBuildPhase;
45 | buildActionMask = 2147483647;
46 | files = (
47 | );
48 | runOnlyForDeploymentPostprocessing = 0;
49 | };
50 | /* End PBXFrameworksBuildPhase section */
51 |
52 | /* Begin PBXGroup section */
53 | 03B4D5B91BEBFB74009FA801 = {
54 | isa = PBXGroup;
55 | children = (
56 | 033E87171CD886B3009DD86A /* README.md */,
57 | 03693EB91C14F52600E05BA8 /* activities.gif */,
58 | 03BFB0131C13A9B7003FD9B1 /* athlete.json */,
59 | 03979F111BF9255200C6DF09 /* activities.json */,
60 | 03C316F21DD335B9001B03F8 /* HTTPRequests.swift */,
61 | 03979F1D1C010E3E00C6DF09 /* STAnimatedGIFCreator.swift */,
62 | 03B4D5C41BEBFB74009FA801 /* STStrava */,
63 | 03B4D5C31BEBFB74009FA801 /* Products */,
64 | );
65 | sourceTree = "";
66 | };
67 | 03B4D5C31BEBFB74009FA801 /* Products */ = {
68 | isa = PBXGroup;
69 | children = (
70 | 03B4D5C21BEBFB74009FA801 /* STStrava.app */,
71 | );
72 | name = Products;
73 | sourceTree = "";
74 | };
75 | 03B4D5C41BEBFB74009FA801 /* STStrava */ = {
76 | isa = PBXGroup;
77 | children = (
78 | 03B4D5C51BEBFB74009FA801 /* AppDelegate.swift */,
79 | 03B4D5C71BEBFB74009FA801 /* ViewController.swift */,
80 | 03C776251BF525DA00A135BA /* StravaAPI.swift */,
81 | 03449C691BF4F3EB0037BC6D /* ActivitiesChartView.swift */,
82 | 03B4D5C91BEBFB74009FA801 /* Main.storyboard */,
83 | 03B4D5CC1BEBFB74009FA801 /* Assets.xcassets */,
84 | 03B4D5CE1BEBFB74009FA801 /* LaunchScreen.storyboard */,
85 | 03B4D5D11BEBFB74009FA801 /* Info.plist */,
86 | );
87 | path = STStrava;
88 | sourceTree = "";
89 | };
90 | /* End PBXGroup section */
91 |
92 | /* Begin PBXNativeTarget section */
93 | 03B4D5C11BEBFB74009FA801 /* STStrava */ = {
94 | isa = PBXNativeTarget;
95 | buildConfigurationList = 03B4D5D41BEBFB74009FA801 /* Build configuration list for PBXNativeTarget "STStrava" */;
96 | buildPhases = (
97 | 03B4D5BE1BEBFB74009FA801 /* Sources */,
98 | 03B4D5BF1BEBFB74009FA801 /* Frameworks */,
99 | 03B4D5C01BEBFB74009FA801 /* Resources */,
100 | );
101 | buildRules = (
102 | );
103 | dependencies = (
104 | );
105 | name = STStrava;
106 | productName = STStrava;
107 | productReference = 03B4D5C21BEBFB74009FA801 /* STStrava.app */;
108 | productType = "com.apple.product-type.application";
109 | };
110 | /* End PBXNativeTarget section */
111 |
112 | /* Begin PBXProject section */
113 | 03B4D5BA1BEBFB74009FA801 /* Project object */ = {
114 | isa = PBXProject;
115 | attributes = {
116 | LastUpgradeCheck = 0700;
117 | ORGANIZATIONNAME = "Nicolas Seriot";
118 | TargetAttributes = {
119 | 03B4D5C11BEBFB74009FA801 = {
120 | CreatedOnToolsVersion = 7.0.1;
121 | LastSwiftMigration = 0800;
122 | };
123 | };
124 | };
125 | buildConfigurationList = 03B4D5BD1BEBFB74009FA801 /* Build configuration list for PBXProject "STStrava" */;
126 | compatibilityVersion = "Xcode 3.2";
127 | developmentRegion = English;
128 | hasScannedForEncodings = 0;
129 | knownRegions = (
130 | en,
131 | Base,
132 | );
133 | mainGroup = 03B4D5B91BEBFB74009FA801;
134 | productRefGroup = 03B4D5C31BEBFB74009FA801 /* Products */;
135 | projectDirPath = "";
136 | projectRoot = "";
137 | targets = (
138 | 03B4D5C11BEBFB74009FA801 /* STStrava */,
139 | );
140 | };
141 | /* End PBXProject section */
142 |
143 | /* Begin PBXResourcesBuildPhase section */
144 | 03B4D5C01BEBFB74009FA801 /* Resources */ = {
145 | isa = PBXResourcesBuildPhase;
146 | buildActionMask = 2147483647;
147 | files = (
148 | 03979F121BF9255200C6DF09 /* activities.json in Resources */,
149 | 03B4D5D01BEBFB74009FA801 /* LaunchScreen.storyboard in Resources */,
150 | 03B4D5CD1BEBFB74009FA801 /* Assets.xcassets in Resources */,
151 | 03693EBA1C14F52600E05BA8 /* activities.gif in Resources */,
152 | 03B4D5CB1BEBFB74009FA801 /* Main.storyboard in Resources */,
153 | 03BFB0141C13A9B7003FD9B1 /* athlete.json in Resources */,
154 | );
155 | runOnlyForDeploymentPostprocessing = 0;
156 | };
157 | /* End PBXResourcesBuildPhase section */
158 |
159 | /* Begin PBXSourcesBuildPhase section */
160 | 03B4D5BE1BEBFB74009FA801 /* Sources */ = {
161 | isa = PBXSourcesBuildPhase;
162 | buildActionMask = 2147483647;
163 | files = (
164 | 03449C6A1BF4F3EB0037BC6D /* ActivitiesChartView.swift in Sources */,
165 | 03C776261BF525DA00A135BA /* StravaAPI.swift in Sources */,
166 | 03979F1E1C010E3E00C6DF09 /* STAnimatedGIFCreator.swift in Sources */,
167 | 03C316F31DD335B9001B03F8 /* HTTPRequests.swift in Sources */,
168 | 03B4D5C81BEBFB74009FA801 /* ViewController.swift in Sources */,
169 | 03B4D5C61BEBFB74009FA801 /* AppDelegate.swift in Sources */,
170 | );
171 | runOnlyForDeploymentPostprocessing = 0;
172 | };
173 | /* End PBXSourcesBuildPhase section */
174 |
175 | /* Begin PBXVariantGroup section */
176 | 03B4D5C91BEBFB74009FA801 /* Main.storyboard */ = {
177 | isa = PBXVariantGroup;
178 | children = (
179 | 03B4D5CA1BEBFB74009FA801 /* Base */,
180 | );
181 | name = Main.storyboard;
182 | sourceTree = "";
183 | };
184 | 03B4D5CE1BEBFB74009FA801 /* LaunchScreen.storyboard */ = {
185 | isa = PBXVariantGroup;
186 | children = (
187 | 03B4D5CF1BEBFB74009FA801 /* Base */,
188 | );
189 | name = LaunchScreen.storyboard;
190 | sourceTree = "";
191 | };
192 | /* End PBXVariantGroup section */
193 |
194 | /* Begin XCBuildConfiguration section */
195 | 03B4D5D21BEBFB74009FA801 /* Debug */ = {
196 | isa = XCBuildConfiguration;
197 | buildSettings = {
198 | ALWAYS_SEARCH_USER_PATHS = NO;
199 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
200 | CLANG_CXX_LIBRARY = "libc++";
201 | CLANG_ENABLE_MODULES = YES;
202 | CLANG_ENABLE_OBJC_ARC = YES;
203 | CLANG_WARN_BOOL_CONVERSION = YES;
204 | CLANG_WARN_CONSTANT_CONVERSION = YES;
205 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
206 | CLANG_WARN_EMPTY_BODY = YES;
207 | CLANG_WARN_ENUM_CONVERSION = YES;
208 | CLANG_WARN_INT_CONVERSION = YES;
209 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
210 | CLANG_WARN_UNREACHABLE_CODE = YES;
211 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
212 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
213 | COPY_PHASE_STRIP = NO;
214 | DEBUG_INFORMATION_FORMAT = dwarf;
215 | ENABLE_STRICT_OBJC_MSGSEND = YES;
216 | ENABLE_TESTABILITY = YES;
217 | GCC_C_LANGUAGE_STANDARD = gnu99;
218 | GCC_DYNAMIC_NO_PIC = NO;
219 | GCC_NO_COMMON_BLOCKS = YES;
220 | GCC_OPTIMIZATION_LEVEL = 0;
221 | GCC_PREPROCESSOR_DEFINITIONS = (
222 | "DEBUG=1",
223 | "$(inherited)",
224 | );
225 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
226 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
227 | GCC_WARN_UNDECLARED_SELECTOR = YES;
228 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
229 | GCC_WARN_UNUSED_FUNCTION = YES;
230 | GCC_WARN_UNUSED_VARIABLE = YES;
231 | IPHONEOS_DEPLOYMENT_TARGET = 9.0;
232 | MTL_ENABLE_DEBUG_INFO = YES;
233 | ONLY_ACTIVE_ARCH = YES;
234 | SDKROOT = iphoneos;
235 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
236 | };
237 | name = Debug;
238 | };
239 | 03B4D5D31BEBFB74009FA801 /* Release */ = {
240 | isa = XCBuildConfiguration;
241 | buildSettings = {
242 | ALWAYS_SEARCH_USER_PATHS = NO;
243 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
244 | CLANG_CXX_LIBRARY = "libc++";
245 | CLANG_ENABLE_MODULES = YES;
246 | CLANG_ENABLE_OBJC_ARC = YES;
247 | CLANG_WARN_BOOL_CONVERSION = YES;
248 | CLANG_WARN_CONSTANT_CONVERSION = YES;
249 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
250 | CLANG_WARN_EMPTY_BODY = YES;
251 | CLANG_WARN_ENUM_CONVERSION = YES;
252 | CLANG_WARN_INT_CONVERSION = YES;
253 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
254 | CLANG_WARN_UNREACHABLE_CODE = YES;
255 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
256 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
257 | COPY_PHASE_STRIP = NO;
258 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
259 | ENABLE_NS_ASSERTIONS = NO;
260 | ENABLE_STRICT_OBJC_MSGSEND = YES;
261 | GCC_C_LANGUAGE_STANDARD = gnu99;
262 | GCC_NO_COMMON_BLOCKS = YES;
263 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
264 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
265 | GCC_WARN_UNDECLARED_SELECTOR = YES;
266 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
267 | GCC_WARN_UNUSED_FUNCTION = YES;
268 | GCC_WARN_UNUSED_VARIABLE = YES;
269 | IPHONEOS_DEPLOYMENT_TARGET = 9.0;
270 | MTL_ENABLE_DEBUG_INFO = NO;
271 | SDKROOT = iphoneos;
272 | VALIDATE_PRODUCT = YES;
273 | };
274 | name = Release;
275 | };
276 | 03B4D5D51BEBFB74009FA801 /* Debug */ = {
277 | isa = XCBuildConfiguration;
278 | buildSettings = {
279 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
280 | INFOPLIST_FILE = STStrava/Info.plist;
281 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
282 | PRODUCT_BUNDLE_IDENTIFIER = ch.seriot.STStrava;
283 | PRODUCT_NAME = "$(TARGET_NAME)";
284 | SWIFT_VERSION = 3.0;
285 | };
286 | name = Debug;
287 | };
288 | 03B4D5D61BEBFB74009FA801 /* Release */ = {
289 | isa = XCBuildConfiguration;
290 | buildSettings = {
291 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
292 | INFOPLIST_FILE = STStrava/Info.plist;
293 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
294 | PRODUCT_BUNDLE_IDENTIFIER = ch.seriot.STStrava;
295 | PRODUCT_NAME = "$(TARGET_NAME)";
296 | SWIFT_VERSION = 3.0;
297 | };
298 | name = Release;
299 | };
300 | /* End XCBuildConfiguration section */
301 |
302 | /* Begin XCConfigurationList section */
303 | 03B4D5BD1BEBFB74009FA801 /* Build configuration list for PBXProject "STStrava" */ = {
304 | isa = XCConfigurationList;
305 | buildConfigurations = (
306 | 03B4D5D21BEBFB74009FA801 /* Debug */,
307 | 03B4D5D31BEBFB74009FA801 /* Release */,
308 | );
309 | defaultConfigurationIsVisible = 0;
310 | defaultConfigurationName = Release;
311 | };
312 | 03B4D5D41BEBFB74009FA801 /* Build configuration list for PBXNativeTarget "STStrava" */ = {
313 | isa = XCConfigurationList;
314 | buildConfigurations = (
315 | 03B4D5D51BEBFB74009FA801 /* Debug */,
316 | 03B4D5D61BEBFB74009FA801 /* Release */,
317 | );
318 | defaultConfigurationIsVisible = 0;
319 | defaultConfigurationName = Release;
320 | };
321 | /* End XCConfigurationList section */
322 | };
323 | rootObject = 03B4D5BA1BEBFB74009FA801 /* Project object */;
324 | }
325 |
--------------------------------------------------------------------------------
/STStrava/ActivitiesChartView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // STActivitiesChartView.swift
3 | // STStrava
4 | //
5 | // Created by Nicolas Seriot on 12/11/15.
6 | // Copyright © 2015 Nicolas Seriot. All rights reserved.
7 | //
8 |
9 | // other_athlete activities
10 | // https://www.strava.com/api/v3/athletes/9228133/activities?access_token=xxx
11 | // {"message":"Authorization Error","errors":[]}
12 | // -> probably, key for official client is needed
13 |
14 | import UIKit
15 |
16 | let LEFT_SCALE_WIDTH : Double = 30
17 | let BOTTOM_SCALE_HEIGHT : Double = 30
18 | let MAX_SECONDS : Int = Int(60 * 60 * 6.5)
19 | let MAX_METERS : Int = 50000
20 | let ACTIVITY_RADIUS = 4
21 |
22 | func textWidth(_ text:NSString, font:UIFont, context: NSStringDrawingContext?) -> CGFloat {
23 | let maxSize : CGSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: font.pointSize)
24 | let textRect : CGRect = text.boundingRect(
25 | with: maxSize,
26 | options: NSStringDrawingOptions.usesLineFragmentOrigin,
27 | attributes: [NSFontAttributeName: font],
28 | context: context)
29 | return textRect.size.width
30 | }
31 |
32 | class ActivitiesChartView: UIView {
33 |
34 | fileprivate var activities : [Activity]
35 |
36 | fileprivate var athlete : Athlete?
37 | fileprivate var maxElevationMetersRounded : Int = 0
38 | fileprivate var totalDistance : Double?
39 | fileprivate var totalDuration : Int?
40 | fileprivate var firstDate : Date?
41 | fileprivate var lastDate : Date?
42 |
43 | override init(frame: CGRect) {
44 | self.activities = []
45 | super.init(frame:frame)
46 | self.maxElevationMetersRounded = 0
47 | self.backgroundColor = UIColor.white
48 | }
49 |
50 | required init(coder aDecoder: NSCoder) {
51 | fatalError("init(coder:) has not been implemented")
52 | }
53 |
54 | func drawInContext(_ context : CGContext) {
55 | context.setAllowsAntialiasing(true) // smaller file size when true
56 |
57 | context.setFillColor(UIColor.white.cgColor)
58 | context.fill(self.frame)
59 |
60 | drawBottomScale(context)
61 | drawLeftScale(context)
62 | drawTitleAndSubtitles(context)
63 | drawElevationLegend(context)
64 | drawPaces(context)
65 |
66 | for (index, a) in self.activities.enumerated() {
67 | let isLastActivity = index == activities.count-1
68 | drawActivityDot(context, activity:a, highlight:isLastActivity)
69 | if(isLastActivity) {
70 | drawLegendWithActivityDetails(context, activity:a)
71 | }
72 | }
73 | }
74 |
75 | override func draw(_ rect: CGRect) {
76 | guard let context = UIGraphicsGetCurrentContext() else { return }
77 |
78 | drawInContext(context)
79 | }
80 |
81 | func xForSeconds(_ seconds:Int) -> Double {
82 | let xAxisWidth = Double(self.frame.size.width) - LEFT_SCALE_WIDTH
83 | return LEFT_SCALE_WIDTH + xAxisWidth * Double(seconds) / Double(MAX_SECONDS)
84 | }
85 |
86 | func yForMeters(_ meters:Double) -> Double {
87 | let yAxisHeight = Double(self.frame.size.height) - BOTTOM_SCALE_HEIGHT
88 | return Double(self.frame.size.height) - BOTTOM_SCALE_HEIGHT - yAxisHeight * meters / Double(MAX_METERS)
89 | }
90 |
91 | func maxElevationMetersRounded(_ activities:[Activity]) -> Int {
92 | guard activities.count > 0 else { return 0 }
93 |
94 | let maxElevation = activities.map{$0.elevationGain}.max()
95 | guard let existingMaxElevation = maxElevation else {
96 | return 0
97 | }
98 |
99 | let maxElevationRounded = (existingMaxElevation + 100) - (existingMaxElevation + 100).truncatingRemainder(dividingBy: 100)
100 | return Int(maxElevationRounded)
101 | }
102 |
103 | func colorForElevationGain(_ elevationMeters:Double) -> UIColor {
104 | let maxElevationMeters : Int = self.maxElevationMetersRounded
105 | let r = min(elevationMeters, Double(maxElevationMeters)) / Double(maxElevationMeters)
106 | let g = 1.0 - r
107 | let b = 0.0
108 |
109 | return UIColor(red: CGFloat(r), green: CGFloat(g), blue: CGFloat(b), alpha: CGFloat(1.0))
110 | }
111 |
112 | func drawText(_ context : CGContext, x: CGFloat, y: CGFloat, text : String, rotationAngle : CGFloat) {
113 |
114 | let font = UIFont.systemFont(ofSize: 14)
115 | let attr = [NSFontAttributeName:font, NSForegroundColorAttributeName:UIColor.black]
116 |
117 | context.saveGState()
118 |
119 | if(rotationAngle != 0.0) {
120 | let width : CGFloat = 0.0 //textWidth(text, font: font, context: nil)
121 | context.translateBy(x: x + width / 2.0, y: y);
122 | context.rotate(by: rotationAngle)
123 | context.translateBy(x: -x - width / 2.0, y: -y);
124 | }
125 |
126 | text.draw(at: CGPoint(x: x, y: y), withAttributes: attr)
127 |
128 | context.restoreGState()
129 | }
130 |
131 | func drawBottomScale(_ context: CGContext) {
132 |
133 | context.saveGState()
134 |
135 | let color = UIColor.black
136 | context.setStrokeColor(color.cgColor)
137 | context.move(to: CGPoint(x: CGFloat(LEFT_SCALE_WIDTH), y: self.frame.size.height - CGFloat(BOTTOM_SCALE_HEIGHT)))
138 | context.addLine(to: CGPoint(x: self.frame.size.width, y: self.frame.size.height - CGFloat(BOTTOM_SCALE_HEIGHT)))
139 |
140 | for tick120Seconds in stride(from: (10*60), through: MAX_SECONDS, by: 10*60) {
141 | let isMajorTick = tick120Seconds % (30*60) == 0
142 | let x = xForSeconds(tick120Seconds)
143 | let tickStart = isMajorTick ? 0 : self.frame.size.height - CGFloat(BOTTOM_SCALE_HEIGHT)
144 | let tickStop = isMajorTick ? self.frame.size.height - CGFloat(BOTTOM_SCALE_HEIGHT) + 10 : self.frame.size.height - CGFloat(BOTTOM_SCALE_HEIGHT) + 5
145 | context.move(to: CGPoint(x: CGFloat(x), y: CGFloat(tickStart)))
146 | context.addLine(to: CGPoint(x: CGFloat(x), y: CGFloat(tickStop)))
147 |
148 | let hours : Int = tick120Seconds / 3600
149 | let minutes : Int = (tick120Seconds % 3600) / 60
150 |
151 | let minutesString = String(format: "%02d", minutes)
152 |
153 | if isMajorTick {
154 | drawText(context, x: CGFloat(x-15), y: CGFloat(self.frame.size.height - CGFloat(BOTTOM_SCALE_HEIGHT) + 10), text: "\(hours):\(minutesString)", rotationAngle: 0)
155 | }
156 | }
157 |
158 | context.strokePath()
159 |
160 | context.restoreGState()
161 | }
162 |
163 | func drawLeftScale(_ context: CGContext) {
164 |
165 | context.saveGState()
166 |
167 | let color = UIColor.black
168 | context.setStrokeColor(color.cgColor)
169 | context.move(to: CGPoint(x: CGFloat(LEFT_SCALE_WIDTH), y: 0))
170 | context.addLine(to: CGPoint(x: CGFloat(LEFT_SCALE_WIDTH), y: self.frame.size.height - CGFloat(BOTTOM_SCALE_HEIGHT)))
171 |
172 | for tick1000Meters in stride(from: 1000.0, to: Double(MAX_METERS), by: 1000) {
173 | // for var tick1000Meters : Double = 1000; tick1000Meters < Double(MAX_METERS); tick1000Meters += 1000 {
174 | let isMajorTick = tick1000Meters.truncatingRemainder(dividingBy: 5000) == 0
175 | let y = yForMeters(tick1000Meters)
176 | let tickStart = isMajorTick ? LEFT_SCALE_WIDTH - 10 : LEFT_SCALE_WIDTH - 5
177 | let tickStop = isMajorTick ? self.frame.size.width : CGFloat(LEFT_SCALE_WIDTH)
178 | context.move(to: CGPoint(x: CGFloat(tickStart), y: CGFloat(y)))
179 | context.addLine(to: CGPoint(x: CGFloat(tickStop), y: CGFloat(y)))
180 |
181 | if(isMajorTick) {
182 | let tickKilometers : Int = Int(tick1000Meters / 1000)
183 |
184 | let text = "\(tickKilometers)"
185 | let font = UIFont.systemFont(ofSize: 14)
186 |
187 | drawText(
188 | context,
189 | x: CGFloat(LEFT_SCALE_WIDTH) - 12 - textWidth(text as NSString, font:font, context: nil),
190 | y: CGFloat(y - 8),
191 | text: text,
192 | rotationAngle: 0)
193 | }
194 | }
195 |
196 | context.strokePath()
197 |
198 | context.restoreGState()
199 | }
200 |
201 | func drawPaces(_ context: CGContext) {
202 |
203 | let font = UIFont.systemFont(ofSize: 14)
204 | let attr = [NSFontAttributeName:font, NSForegroundColorAttributeName:UIColor.lightGray]
205 | let yMetersForHorizontalDrawing : Double = 40000
206 | let xMinutesForVerticalDrawing = 190
207 |
208 | context.saveGState()
209 |
210 | context.setStrokeColor(UIColor.lightGray.cgColor)
211 | context.setLineWidth(0.5)
212 |
213 | for pace in stride(from: 3.5, through: 7.0, by: 0.5) {
214 | // for var pace : Double = 3.5; pace <= 7.0; pace += 0.5 {
215 | // draw pace line
216 | let xMaxKm : CGFloat = CGFloat(xForSeconds(Int(pace * 60.0 * Double(MAX_METERS) / 1000.0)))
217 | context.move(to: CGPoint(x: CGFloat(LEFT_SCALE_WIDTH), y: self.frame.size.height - CGFloat(BOTTOM_SCALE_HEIGHT)))
218 | context.addLine(to: CGPoint(x: CGFloat(xMaxKm), y: CGFloat(0)))
219 |
220 | // draw pace text
221 | let text = "\(pace)'/Km"
222 |
223 | let deltaX = CGFloat(xMaxKm) - CGFloat(LEFT_SCALE_WIDTH)
224 | let deltaY = -1.0 * (self.frame.size.height - CGFloat(BOTTOM_SCALE_HEIGHT))
225 | let radians = atan(deltaY / deltaX)
226 |
227 | let paceWidth = textWidth(text as NSString, font:font, context: nil)
228 |
229 | let drawPaceVertically = xMaxKm + paceWidth / 2.0 > self.frame.size.width
230 | let pacePointX : CGFloat = drawPaceVertically ? CGFloat(xForSeconds(xMinutesForVerticalDrawing*60)) : CGFloat(xForSeconds(Int(pace * 60.0 * yMetersForHorizontalDrawing / 1000.0)))
231 | let pacePointY : CGFloat = drawPaceVertically ? CGFloat(yForMeters(Double(xMinutesForVerticalDrawing)/pace*1000)) : CGFloat(yForMeters(yMetersForHorizontalDrawing))
232 | let pacePoint = CGPoint(x: pacePointX, y: pacePointY)
233 |
234 | context.saveGState()
235 |
236 | context.translateBy(x: pacePoint.x, y: pacePoint.y);
237 | context.rotate(by: radians)
238 | context.translateBy(x: -1.0 * pacePoint.x, y: -1.0 * pacePoint.y);
239 |
240 | text.draw(at: pacePoint, withAttributes: attr)
241 |
242 | context.restoreGState()
243 | }
244 |
245 | context.strokePath()
246 |
247 | context.restoreGState()
248 | }
249 |
250 | func drawActivityDot(_ context: CGContext, activity: Activity, highlight: Bool) {
251 |
252 | context.saveGState()
253 |
254 | let x = self.xForSeconds(activity.seconds)
255 | let y = self.yForMeters(activity.meters)
256 |
257 | let elevationColor : UIColor = colorForElevationGain(activity.elevationGain)
258 | context.setStrokeColor(UIColor.black.cgColor)
259 | context.setFillColor(elevationColor.cgColor)
260 | context.setLineWidth(highlight ? 2 : 0.5)
261 |
262 | context.addEllipse(in: CGRect(
263 | x: CGFloat(x - Double(ACTIVITY_RADIUS)),
264 | y: CGFloat(y - Double(ACTIVITY_RADIUS)),
265 | width: CGFloat(ACTIVITY_RADIUS) * 2,
266 | height: CGFloat(ACTIVITY_RADIUS) * 2))
267 |
268 | context.drawPath(using: .fillStroke);
269 |
270 | context.restoreGState()
271 | }
272 |
273 | func drawTitleAndSubtitles(_ context: CGContext) {
274 |
275 | guard let existingAthlete = athlete else {
276 | return
277 | }
278 |
279 | guard self.totalDistance != nil else {
280 | return
281 | }
282 |
283 | let font = UIFont.systemFont(ofSize: 14)
284 | let attr = [NSFontAttributeName:font, NSForegroundColorAttributeName:UIColor.black]
285 |
286 | context.saveGState()
287 |
288 | // frame
289 |
290 | let p0 = CGPoint(x: CGFloat(LEFT_SCALE_WIDTH + 10.0), y: 10)
291 |
292 | let titleFrame = CGRect(x: p0.x, y: p0.y, width: 235, height: 80)
293 | context.fill(titleFrame)
294 | context.stroke(titleFrame, width: 1.0)
295 |
296 | // labels
297 |
298 | let LINE_HEIGHT : CGFloat = 18
299 |
300 | let title = "Runner: \(existingAthlete.firstname!) \(existingAthlete.lastname!)"
301 | let l1 = CGPoint(x: p0.x + 10, y: p0.y + 5)
302 | title.draw(at: l1, withAttributes: attr)
303 |
304 | if(firstDate != nil && lastDate != nil) {
305 | let dateFormatter = DateFormatter()
306 | dateFormatter.dateFormat = "yyyy-MM-dd"
307 | let date1 = dateFormatter.string(from: self.firstDate!)
308 | let date2 = dateFormatter.string(from: self.lastDate!)
309 | let datesString = "Period: \(date1) - \(date2)"
310 | let l2 = CGPoint(x: l1.x, y: l1.y + LINE_HEIGHT)
311 | datesString.draw(at: l2, withAttributes: attr)
312 | }
313 |
314 | let distanceKm = String(format: "%0.2f", self.totalDistance!/1000)
315 | let distance = "Total distance: \(distanceKm) Km"
316 | let l3 = CGPoint(x: l1.x, y: l1.y + LINE_HEIGHT*2)
317 | distance.draw(at: l3, withAttributes: attr)
318 |
319 | let durationHours = String(format: "%02d", self.totalDuration!/3600)
320 | let durationMinutes = String(format: "%02d", (self.totalDuration! % 3600) / 60)
321 | let duration = "Total duration: \(durationHours):\(durationMinutes) hours"
322 | let l4 = CGPoint(x: l1.x, y: l3.y + LINE_HEIGHT)
323 | duration.draw(at: l4, withAttributes: attr)
324 |
325 | context.restoreGState()
326 | }
327 |
328 | func drawElevationLegend(_ context: CGContext) {
329 |
330 | guard athlete != nil else {
331 | return
332 | }
333 |
334 | let maxElevationMeters = self.maxElevationMetersRounded
335 |
336 | let font = UIFont.systemFont(ofSize: 14)
337 | let attr = [NSFontAttributeName:font, NSForegroundColorAttributeName:UIColor.black]
338 |
339 | context.saveGState()
340 |
341 | // compute elevations
342 |
343 | var elevations : [Int] = []
344 | for elevation in stride(from: 0, through: maxElevationMeters, by: 100) {
345 | // for(var elevation = 0; elevation <= maxElevationMeters; elevation += 100) {
346 | elevations.append(elevation)
347 | }
348 | elevations = elevations.reversed()
349 |
350 | // frame
351 |
352 | /*
353 | p0---p1
354 | | |
355 | p3---p2
356 | */
357 |
358 | let LINE_HEIGHT = 12
359 | let p0 = CGPoint(x: CGFloat(LEFT_SCALE_WIDTH + 10.0), y: 100)
360 | let p2 = CGPoint(x: p0.x + 90, y: p0.y + 30 + CGFloat(elevations.count * LINE_HEIGHT))
361 |
362 | let titleFrame = CGRect(x: p0.x, y: p0.y, width: p2.x - p0.x, height: p2.y - p0.y)
363 | context.fill(titleFrame)
364 | context.stroke(titleFrame, width: 1.0)
365 |
366 | let p = CGPoint(x: p0.x + 10, y: p0.y + 5)
367 | "Elevation".draw(at: p, withAttributes: attr)
368 |
369 | for elevation in elevations {
370 | let x = CGFloat(p.x + 5)
371 | let y = Double(p.y) + 25 + Double(maxElevationMeters - elevation) / 100.0 * Double(LINE_HEIGHT)
372 |
373 | let elevationColor : UIColor = colorForElevationGain(Double(elevation))
374 |
375 | context.setStrokeColor(UIColor.black.cgColor)
376 | context.setFillColor(elevationColor.cgColor)
377 | context.setLineWidth(0.5)
378 |
379 | context.addEllipse(in: CGRect(
380 | x: CGFloat(x - CGFloat(ACTIVITY_RADIUS)),
381 | y: CGFloat(y - Double(ACTIVITY_RADIUS)),
382 | width: CGFloat(ACTIVITY_RADIUS) * 2,
383 | height: CGFloat(ACTIVITY_RADIUS) * 2))
384 |
385 | context.drawPath(using: .fillStroke);
386 |
387 | if(elevation == maxElevationMeters) {
388 | let s = "+\(elevation) m"
389 | let p = CGPoint(x: x + CGFloat(10.0), y: CGFloat(y) - 10.0)
390 | s.draw(at: p, withAttributes: attr)
391 | }
392 | }
393 |
394 | context.restoreGState()
395 | }
396 |
397 | func drawLegendWithActivityDetails(_ context: CGContext, activity: Activity) {
398 |
399 | let font = UIFont.systemFont(ofSize: 14)
400 | let attr = [NSFontAttributeName:font, NSForegroundColorAttributeName:UIColor.black]
401 |
402 | let dateFormatter = DateFormatter()
403 | dateFormatter.dateFormat = "yyyy-MM-dd HH:mm"
404 |
405 | context.saveGState()
406 |
407 | // frame
408 |
409 | let p2 = CGPoint(x: CGFloat(self.frame.size.width - 10), y: CGFloat(self.frame.size.height) - CGFloat(BOTTOM_SCALE_HEIGHT) - 10)
410 | let p0 = CGPoint(x: p2.x - 400, y: p2.y - 45)
411 |
412 | let titleFrame = CGRect(x: p0.x, y: p0.y, width: p2.x - p0.x, height: p2.y - p0.y)
413 | context.fill(titleFrame)
414 | context.stroke(titleFrame, width: 1.0)
415 |
416 | // labels
417 |
418 | let LINE_HEIGHT : CGFloat = 18
419 |
420 | let l1 = CGPoint(x: p0.x + 10, y: p0.y + 5)
421 | let prettyDate = dateFormatter.string(from: activity.date as Date)
422 |
423 | var dateAndPlace = "\(prettyDate)"
424 | if let city = activity.locationCity {
425 | dateAndPlace.append(", \(city)")
426 | }
427 | dateAndPlace.draw(at: l1, withAttributes: attr)
428 | let l2 = CGPoint(x: l1.x, y: l1.y + LINE_HEIGHT)
429 | activity.name.draw(at: l2, withAttributes: attr)
430 |
431 | context.restoreGState()
432 | }
433 |
434 | func drawInImageContext() -> UIImage {
435 | UIGraphicsBeginImageContextWithOptions(self.frame.size, false, UIScreen.main.scale)
436 | guard let context = UIGraphicsGetCurrentContext() as CGContext? else { fatalError() }
437 | drawInContext(context)
438 | let image = UIGraphicsGetImageFromCurrentImageContext()
439 | UIGraphicsEndImageContext()
440 | guard let existingImage = image else { assertionFailure(); return UIImage() }
441 | return existingImage;
442 | }
443 |
444 | func setupGlobalStatsFromActivities(_ activities: [Activity]) {
445 | self.maxElevationMetersRounded = self.maxElevationMetersRounded(activities)
446 | self.totalDistance = activities.reduce(0) { $0! + $1.meters}
447 | self.totalDuration = activities.reduce(0) { $0! + $1.seconds}
448 |
449 | self.firstDate = activities.first?.date as Date?
450 | self.lastDate = activities.last?.date as Date?
451 | }
452 |
453 | func setData(_ athlete: Athlete, activities: [Activity]) {
454 | self.athlete = athlete
455 | self.activities = activities
456 | self.setupGlobalStatsFromActivities(activities)
457 | self.setNeedsDisplay()
458 | }
459 |
460 | func setProgressiveData(_ athlete: Athlete, activities: [Activity]) {
461 | self.athlete = athlete
462 | self.activities = activities
463 | }
464 |
465 |
466 | // /*
467 | // pierrick 434391
468 | // sebastien 9228133
469 | // */
470 | // api.fetchFriendsActivities(434391) { (result) -> () in
471 | //
472 | // switch(result) {
473 | // case let .Success(activities):
474 | //
475 | // if let otherAthlete = activities.first?.athlete {
476 | // self.athlete = otherAthlete
477 | // }
478 | //
479 | // self.activities = activities
480 | //
481 | // self.setupGlobalStatsFromActivities(activities)
482 | //
483 | // self.setNeedsDisplay()
484 | //
485 | // self.createAnimatedGIF({ (gifPath) -> () in
486 | // print("--", gifPath)
487 | // })
488 | //
489 | // case let .Failure(error):
490 | // print(error)
491 | // return
492 | // }
493 | // }
494 |
495 | }
496 |
497 |
--------------------------------------------------------------------------------
/activities.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": 431335153,
4 | "resource_state": 2,
5 | "external_id": "runtastic_20151112_1152_Running.gpx",
6 | "upload_id": 481655745,
7 | "athlete": {
8 | "id": 11730283,
9 | "resource_state": 1
10 | },
11 | "name": "le Golf avec Seb et Florian",
12 | "distance": 8101,
13 | "moving_time": 2327,
14 | "elapsed_time": 2327,
15 | "total_elevation_gain": 44.2,
16 | "type": "Run",
17 | "start_date": "2015-11-12T11:13:45Z",
18 | "start_date_local": "2015-11-12T12:13:45Z",
19 | "timezone": "(GMT+01:00) Europe\/Zurich",
20 | "start_latlng": [
21 | 46.42,
22 | 6.27
23 | ],
24 | "end_latlng": [
25 | 46.42,
26 | 6.27
27 | ],
28 | "location_city": "Gland",
29 | "location_state": "Vaud",
30 | "location_country": "Switzerland",
31 | "start_latitude": 46.42,
32 | "start_longitude": 6.27,
33 | "achievement_count": 3,
34 | "kudos_count": 0,
35 | "comment_count": 0,
36 | "athlete_count": 2,
37 | "photo_count": 0,
38 | "map": {
39 | "id": "a431335153",
40 | "summary_polyline": "}{hzGgdge@fFcHvA@zK_TdMsL`HxGhGuNdRjCxRia@hEcDjGP~AzHQrRoE~Fk@`ExMgBnAdGsJdJm@xF|FaAxFoGnA`F|Ef@{KjFaCpNqMwES}GiGwAoHv@g@`IoMu@cBtIaDOeE|DkRnZyEmEpFkGqDsF{CiDsC|DyPwP",
41 | "resource_state": 2
42 | },
43 | "trainer": false,
44 | "commute": false,
45 | "manual": false,
46 | "private": false,
47 | "flagged": false,
48 | "gear_id": null,
49 | "average_speed": 3.481,
50 | "max_speed": 5.3,
51 | "total_photo_count": 0,
52 | "has_kudoed": false,
53 | "workout_type": null
54 | },
55 | {
56 | "id": 430058569,
57 | "resource_state": 2,
58 | "external_id": "runtastic_20151110_1126_Running.gpx",
59 | "upload_id": 480346263,
60 | "athlete": {
61 | "id": 11730283,
62 | "resource_state": 1
63 | },
64 | "name": "Lunch Run",
65 | "distance": 7955.7,
66 | "moving_time": 2135,
67 | "elapsed_time": 2135,
68 | "total_elevation_gain": 34.2,
69 | "type": "Run",
70 | "start_date": "2015-11-10T10:50:30Z",
71 | "start_date_local": "2015-11-10T11:50:30Z",
72 | "timezone": "(GMT+01:00) Europe\/Zurich",
73 | "start_latlng": [
74 | 46.42,
75 | 6.27
76 | ],
77 | "end_latlng": [
78 | 46.42,
79 | 6.27
80 | ],
81 | "location_city": "Gland",
82 | "location_state": "Vaud",
83 | "location_country": "Switzerland",
84 | "start_latitude": 46.42,
85 | "start_longitude": 6.27,
86 | "achievement_count": 7,
87 | "kudos_count": 1,
88 | "comment_count": 0,
89 | "athlete_count": 1,
90 | "photo_count": 0,
91 | "map": {
92 | "id": "a430058569",
93 | "summary_polyline": "q|hzGwdge@tF{GnAT|KkT~LmLfHhGtGaOtOlBhGeL`B[lIuQhH_EdDXv@`Cn@lR]~FiE|Ea@jDxMkBbAlGmJ~Io@nFlGi@rFmGxArFdERaKzEoCxNgN_FEsGeGmAqHd@mNhGkBfJ}CUmE`EoCnHwBZmIvOcF_EjFkG{F}IyI{KiDvFkH{G",
94 | "resource_state": 2
95 | },
96 | "trainer": false,
97 | "commute": false,
98 | "manual": false,
99 | "private": false,
100 | "flagged": false,
101 | "gear_id": null,
102 | "average_speed": 3.726,
103 | "max_speed": 5.3,
104 | "total_photo_count": 0,
105 | "has_kudoed": false,
106 | "workout_type": null
107 | },
108 | {
109 | "id": 429125117,
110 | "resource_state": 2,
111 | "external_id": "runtastic_20151108_1512_Running.gpx",
112 | "upload_id": 479380698,
113 | "athlete": {
114 | "id": 11730283,
115 | "resource_state": 1
116 | },
117 | "name": "Afternoon Run",
118 | "distance": 10599.7,
119 | "moving_time": 3155,
120 | "elapsed_time": 3155,
121 | "total_elevation_gain": 216.8,
122 | "type": "Run",
123 | "start_date": "2015-11-08T14:18:53Z",
124 | "start_date_local": "2015-11-08T15:18:53Z",
125 | "timezone": "(GMT+01:00) Europe\/Zurich",
126 | "start_latlng": [
127 | 46.63,
128 | 6.7
129 | ],
130 | "end_latlng": [
131 | 46.63,
132 | 6.7
133 | ],
134 | "location_city": "Villars-Tiercelin",
135 | "location_state": "Vaud",
136 | "location_country": "Switzerland",
137 | "start_latitude": 46.63,
138 | "start_longitude": 6.7,
139 | "achievement_count": 6,
140 | "kudos_count": 0,
141 | "comment_count": 0,
142 | "athlete_count": 1,
143 | "photo_count": 0,
144 | "map": {
145 | "id": "a429125117",
146 | "summary_polyline": "evq{Ggj|g@{UyNsb@mJyTcM}IQkRqLH{E~Jm@tC{Hq@qCrJmK{GmC_@}DGvDeIgAsXa[oHzIiGsImDlK^vFtHxG~l@fQbR_Q~K`M`RvHvNAVsCvMrFHpC|KlMvHsTjCs@xG`DhHpK{NnMiLgOeEbLyFmEw@n@`@bC_AeBk@z@eB|LbHxK_@fFdBjF~EzD",
147 | "resource_state": 2
148 | },
149 | "trainer": false,
150 | "commute": false,
151 | "manual": false,
152 | "private": false,
153 | "flagged": false,
154 | "gear_id": null,
155 | "average_speed": 3.36,
156 | "max_speed": 5.2,
157 | "total_photo_count": 0,
158 | "has_kudoed": false,
159 | "workout_type": null
160 | },
161 | {
162 | "id": 428317001,
163 | "resource_state": 2,
164 | "external_id": "runtastic_20151107_1456_Running.gpx",
165 | "upload_id": 478536118,
166 | "athlete": {
167 | "id": 11730283,
168 | "resource_state": 1
169 | },
170 | "name": "Afternoon Run",
171 | "distance": 10948.2,
172 | "moving_time": 3276,
173 | "elapsed_time": 3282,
174 | "total_elevation_gain": 246.8,
175 | "type": "Run",
176 | "start_date": "2015-11-07T14:00:52Z",
177 | "start_date_local": "2015-11-07T15:00:52Z",
178 | "timezone": "(GMT+01:00) Europe\/Zurich",
179 | "start_latlng": [
180 | 46.63,
181 | 6.7
182 | ],
183 | "end_latlng": [
184 | 46.63,
185 | 6.7
186 | ],
187 | "location_city": "Villars-Tiercelin",
188 | "location_state": "Vaud",
189 | "location_country": "Switzerland",
190 | "start_latitude": 46.63,
191 | "start_longitude": 6.7,
192 | "achievement_count": 4,
193 | "kudos_count": 0,
194 | "comment_count": 0,
195 | "athlete_count": 1,
196 | "photo_count": 0,
197 | "map": {
198 | "id": "a428317001",
199 | "summary_polyline": "avq{Gsj|g@mVsNwb@oJiTwLoJ_@{X{Ss\\_HsFrLpJdIsCxEoC{@zB`Cc@pAwDeFoK_GwEh@yA_DyJ_DgD_CaBoEiCbBIdLxGrH~G{KtFjAx@bD`KXzJdKtKbFdBoCpAkP_@iLrAnDdAxOzC`I_BxCf@hIiCpKFpJqAhItDzDtVbItOuA`ZzYjCiIFcL|AsCf[_FvTyIlDoEgBiB",
200 | "resource_state": 2
201 | },
202 | "trainer": false,
203 | "commute": false,
204 | "manual": false,
205 | "private": false,
206 | "flagged": false,
207 | "gear_id": null,
208 | "average_speed": 3.342,
209 | "max_speed": 4.7,
210 | "total_photo_count": 0,
211 | "has_kudoed": false,
212 | "workout_type": null
213 | },
214 | {
215 | "id": 426295219,
216 | "resource_state": 2,
217 | "external_id": "runtastic_20151104_1208_Running.gpx",
218 | "upload_id": 476450812,
219 | "athlete": {
220 | "id": 11730283,
221 | "resource_state": 1
222 | },
223 | "name": "Lunch Run",
224 | "distance": 13774.7,
225 | "moving_time": 4485,
226 | "elapsed_time": 4500,
227 | "total_elevation_gain": 272.6,
228 | "type": "Run",
229 | "start_date": "2015-11-04T10:52:51Z",
230 | "start_date_local": "2015-11-04T11:52:51Z",
231 | "timezone": "(GMT+01:00) Europe\/Zurich",
232 | "start_latlng": [
233 | 46.42,
234 | 6.27
235 | ],
236 | "end_latlng": [
237 | 46.42,
238 | 6.27
239 | ],
240 | "location_city": "Gland",
241 | "location_state": "Vaud",
242 | "location_country": "Switzerland",
243 | "start_latitude": 46.42,
244 | "start_longitude": 6.27,
245 | "achievement_count": 0,
246 | "kudos_count": 0,
247 | "comment_count": 0,
248 | "athlete_count": 2,
249 | "photo_count": 0,
250 | "map": {
251 | "id": "a426295219",
252 | "summary_polyline": "c|hzGmege@eA`CiMqNcAfAqc@se@wJbPiEsKaLlN_DoG{GkCwIuR_Zva@eH}F_F|E_DqGiMdLuH}FwD|AkAhCj@jQuBrCm@rJeLlAxIv`@r@`e@~Bp@nAyCdCtBlD_@|CrLEtFxM~HrMaG`E|DzQlBbGbN_DpF}E`BhBhGhCyBlFtAnKoN`Dh@dEwGpAfE~Bq@nEsQjHcDr@}D~E{GfBPdDeGtApBrEGbBlC|IwM|FtGfBo@HyBzGcAvCqGnB^`FmFtBhFxJqXdFmEuH}FrFsGiIwKqC|D}QmP",
253 | "resource_state": 2
254 | },
255 | "trainer": false,
256 | "commute": false,
257 | "manual": false,
258 | "private": false,
259 | "flagged": false,
260 | "gear_id": null,
261 | "average_speed": 3.071,
262 | "max_speed": 6.1,
263 | "total_photo_count": 0,
264 | "has_kudoed": false,
265 | "workout_type": null
266 | },
267 | {
268 | "id": 425659628,
269 | "resource_state": 2,
270 | "external_id": "runtastic_20151103_1227_Running.gpx",
271 | "upload_id": 475797297,
272 | "athlete": {
273 | "id": 11730283,
274 | "resource_state": 1
275 | },
276 | "name": "Lunch Run",
277 | "distance": 10215.5,
278 | "moving_time": 2905,
279 | "elapsed_time": 2905,
280 | "total_elevation_gain": 61.4,
281 | "type": "Run",
282 | "start_date": "2015-11-03T11:39:20Z",
283 | "start_date_local": "2015-11-03T12:39:20Z",
284 | "timezone": "(GMT+01:00) Europe\/Zurich",
285 | "start_latlng": [
286 | 46.42,
287 | 6.27
288 | ],
289 | "end_latlng": [
290 | 46.42,
291 | 6.27
292 | ],
293 | "location_city": "Gland",
294 | "location_state": "Vaud",
295 | "location_country": "Switzerland",
296 | "start_latitude": 46.42,
297 | "start_longitude": 6.27,
298 | "achievement_count": 7,
299 | "kudos_count": 0,
300 | "comment_count": 0,
301 | "athlete_count": 2,
302 | "photo_count": 0,
303 | "map": {
304 | "id": "a425659628",
305 | "summary_polyline": "g|hzG{cge@rQlOhDaEnH|KmFzFbFxEjIePzBKxCgIjE{D~CP~AqIjCcAfJtC~{@ziAbCyD_DqUL{MfI_FhAkIcE{N_LbFwFqG{GiCtKoIyCLwCsGsC|E}IpBv@wFxImJeA{FoMnBzFyNRaPiCyJeGXkDxCyRz`@kR{BuG~NaHaHqM~MgKzR}HrF",
306 | "resource_state": 2
307 | },
308 | "trainer": false,
309 | "commute": false,
310 | "manual": false,
311 | "private": false,
312 | "flagged": false,
313 | "gear_id": null,
314 | "average_speed": 3.517,
315 | "max_speed": 4.9,
316 | "total_photo_count": 0,
317 | "has_kudoed": false,
318 | "workout_type": null
319 | },
320 | {
321 | "id": 422459559,
322 | "resource_state": 2,
323 | "external_id": "runtastic_20151029_1233_Running.gpx",
324 | "upload_id": 472460301,
325 | "athlete": {
326 | "id": 11730283,
327 | "resource_state": 1
328 | },
329 | "name": "Gland-Arzier et retour, 18km 1:40 +380 m",
330 | "distance": 18189,
331 | "moving_time": 6116,
332 | "elapsed_time": 6133,
333 | "total_elevation_gain": 383.2,
334 | "type": "Run",
335 | "start_date": "2015-10-29T10:49:13Z",
336 | "start_date_local": "2015-10-29T11:49:13Z",
337 | "timezone": "(GMT+01:00) Europe\/Zurich",
338 | "start_latlng": [
339 | 46.42,
340 | 6.27
341 | ],
342 | "end_latlng": [
343 | 46.42,
344 | 6.27
345 | ],
346 | "location_city": "Gland",
347 | "location_state": "Vaud",
348 | "location_country": "Switzerland",
349 | "start_latitude": 46.42,
350 | "start_longitude": 6.27,
351 | "achievement_count": 2,
352 | "kudos_count": 0,
353 | "comment_count": 0,
354 | "athlete_count": 2,
355 | "photo_count": 0,
356 | "map": {
357 | "id": "a422459559",
358 | "summary_polyline": "k|hzGafge@bRvQxCcErHxKkFpGrHzGaFlD}J|XiAeANaDcAWeFtFcB]sE`IwELg@zCsDWi@oDqB{@mCjCmB~GcBf@uAeC_I_B{CvFuBIeGpM_HxCsEhRqCTmAwD{BnFoFGoKrNsGuAeHrHsBqAkSdJwBuBaMt[tAjCuAtIwJfMCrEiEfCgHe@uPfEcMwC_XhJ{F`G_GfBgPM|D~@xKjPbCb@`LoGfDsHfNQ~F|G|BRbEcAvEmJ~Ha@fDiEnGxC~P}QYgGxBqLhNkZbV{JvDqFlKjDbGeCnCeEpLmDrFiNzI_KHmHtC_@pKuMiDuJnInAnAlClJyMrFxGfB_@b@yC~GgApCcG~ATzE_F~B`FlJiWjFwFmHgGtFwFmI_LgC`EaRaQ",
359 | "resource_state": 2
360 | },
361 | "trainer": false,
362 | "commute": false,
363 | "manual": false,
364 | "private": false,
365 | "flagged": false,
366 | "gear_id": null,
367 | "average_speed": 2.974,
368 | "max_speed": 5.3,
369 | "total_photo_count": 0,
370 | "has_kudoed": false,
371 | "workout_type": 0
372 | },
373 | {
374 | "id": 421278584,
375 | "resource_state": 2,
376 | "external_id": "runtastic_20151027_1227_Running.gpx",
377 | "upload_id": 471242276,
378 | "athlete": {
379 | "id": 11730283,
380 | "resource_state": 1
381 | },
382 | "name": "16 km 1:30 +457 m",
383 | "distance": 16094.7,
384 | "moving_time": 5422,
385 | "elapsed_time": 5446,
386 | "total_elevation_gain": 439.3,
387 | "type": "Run",
388 | "start_date": "2015-10-27T10:55:48Z",
389 | "start_date_local": "2015-10-27T11:55:48Z",
390 | "timezone": "(GMT+01:00) Europe\/Zurich",
391 | "start_latlng": [
392 | 46.42,
393 | 6.27
394 | ],
395 | "end_latlng": [
396 | 46.42,
397 | 6.27
398 | ],
399 | "location_city": "Gland",
400 | "location_state": "Vaud",
401 | "location_country": "Switzerland",
402 | "start_latitude": 46.42,
403 | "start_longitude": 6.27,
404 | "achievement_count": 1,
405 | "kudos_count": 0,
406 | "comment_count": 0,
407 | "athlete_count": 2,
408 | "photo_count": 0,
409 | "map": {
410 | "id": "a421278584",
411 | "summary_polyline": "m|hzG}ege@kA~BiLeMs@pAsd@of@{JtOiEuKuKlNyCoF{H_DaIcReZ`a@yHoFgEdE_CmCsClBy@mA}GfG_IuFoDzAeA`DX~PyCsHsBbDTkFuDaEC_GwCeMcFoAy@eGkEkA_D_FcA|C{AwEsEb@qCyIoGeDPtUuAk@s@}GiGgIRdEsHpQyA`M`H~EFdBbD^uCvM|Rjh@hd@pTjLWeI}ZMiEpKIHeCzAUlGhAk@cIkBmAs@yGMoLx@kBdDoArIrFdMyKxCjGpEyEvH|FzYma@hIvRnHfCxCjFpKyM|EpKhJgP`e@fg@xAe@hK`LjAcC",
412 | "resource_state": 2
413 | },
414 | "trainer": false,
415 | "commute": false,
416 | "manual": false,
417 | "private": false,
418 | "flagged": false,
419 | "gear_id": null,
420 | "average_speed": 2.968,
421 | "max_speed": 4.6,
422 | "total_photo_count": 0,
423 | "has_kudoed": false,
424 | "workout_type": null
425 | },
426 | {
427 | "id": 420324466,
428 | "resource_state": 2,
429 | "external_id": "runtastic_20151025_1657_Running.gpx",
430 | "upload_id": 470253125,
431 | "athlete": {
432 | "id": 11730283,
433 | "resource_state": 1
434 | },
435 | "name": "10 km 52 min +180 m",
436 | "distance": 10122,
437 | "moving_time": 3122,
438 | "elapsed_time": 3127,
439 | "total_elevation_gain": 183.4,
440 | "type": "Run",
441 | "start_date": "2015-10-25T16:05:14Z",
442 | "start_date_local": "2015-10-25T17:05:14Z",
443 | "timezone": "(GMT+01:00) Europe\/Zurich",
444 | "start_latlng": [
445 | 46.63,
446 | 6.7
447 | ],
448 | "end_latlng": [
449 | 46.63,
450 | 6.7
451 | ],
452 | "location_city": "Villars-Tiercelin",
453 | "location_state": "Vaud",
454 | "location_country": "Switzerland",
455 | "start_latitude": 46.63,
456 | "start_longitude": 6.7,
457 | "achievement_count": 2,
458 | "kudos_count": 0,
459 | "comment_count": 0,
460 | "athlete_count": 1,
461 | "photo_count": 0,
462 | "map": {
463 | "id": "a420324466",
464 | "summary_polyline": "ivq{Gmi|g@xNLzLwFde@~IvEoDx@wC`NsH~BtGsM`O{ArGwPZiCdCrWjN~FeBxCfAsAgMxHqBdBeGxKaOqLsFwFrCsBaHmElDWaD{FyIgLpHwSoCkTeJaQcWqEzKyFmEs@v@`@vBq@}A_Az@eBvKlHzLy@nFkEsD_Et@uc@kIfM|OdGdCzJwJvWdP",
465 | "resource_state": 2
466 | },
467 | "trainer": false,
468 | "commute": false,
469 | "manual": false,
470 | "private": false,
471 | "flagged": false,
472 | "gear_id": null,
473 | "average_speed": 3.242,
474 | "max_speed": 4.9,
475 | "total_photo_count": 0,
476 | "has_kudoed": false,
477 | "workout_type": null
478 | },
479 | {
480 | "id": 419361614,
481 | "resource_state": 2,
482 | "external_id": "runtastic_20151024_1508_Running.gpx",
483 | "upload_id": 469236083,
484 | "athlete": {
485 | "id": 11730283,
486 | "resource_state": 1
487 | },
488 | "name": "Afternoon Run",
489 | "distance": 15303.5,
490 | "moving_time": 4689,
491 | "elapsed_time": 4692,
492 | "total_elevation_gain": 248.2,
493 | "type": "Run",
494 | "start_date": "2015-10-24T13:49:57Z",
495 | "start_date_local": "2015-10-24T15:49:57Z",
496 | "timezone": "(GMT+01:00) Europe\/Zurich",
497 | "start_latlng": [
498 | 46.63,
499 | 6.7
500 | ],
501 | "end_latlng": [
502 | 46.63,
503 | 6.7
504 | ],
505 | "location_city": "Villars-Tiercelin",
506 | "location_state": "Vaud",
507 | "location_country": "Switzerland",
508 | "start_latitude": 46.63,
509 | "start_longitude": 6.7,
510 | "achievement_count": 3,
511 | "kudos_count": 1,
512 | "comment_count": 0,
513 | "athlete_count": 1,
514 | "photo_count": 0,
515 | "map": {
516 | "id": "a419361614",
517 | "summary_polyline": "{vq{Gei|g@dPItKoFfd@fJlSuNx@sClCM@mCpA{@lUxBu@oH_I_MaQpCzAzKrCzA{A`@GbDyCAcA~C_H{NsIdGcJIy^aLkFkHdOqM|BfH~EpBdK{^tB}f@dC}BfBwIhGt@xUrTrEnKKvI{Bj@eK}DsEdBp@qGeAqBuSaKsCfa@cDpIyLaHkDv@iFsFcMs@uDl@GjB_D{@wDhIq`@mLiFaLgKiHsDWeIxExJhMhJlExObDrFm@NyC|MvFItBfLtNyBtDeEkEkAh@ZtBmAuAgCjLlHpNq@zEwEeDeDt@|TdO",
518 | "resource_state": 2
519 | },
520 | "trainer": false,
521 | "commute": false,
522 | "manual": false,
523 | "private": false,
524 | "flagged": false,
525 | "gear_id": null,
526 | "average_speed": 3.264,
527 | "max_speed": 4.8,
528 | "total_photo_count": 0,
529 | "has_kudoed": false,
530 | "workout_type": null
531 | },
532 | {
533 | "id": 414736695,
534 | "resource_state": 2,
535 | "external_id": "runtastic_20151017_1327_Running.gpx",
536 | "upload_id": 464442064,
537 | "athlete": {
538 | "id": 11730283,
539 | "resource_state": 1
540 | },
541 | "name": "2\u00e8me semi de la semaine, 429 m de d\u00e9nivel\u00e9 positif",
542 | "distance": 21357.1,
543 | "moving_time": 6528,
544 | "elapsed_time": 6528,
545 | "total_elevation_gain": 353.7,
546 | "type": "Run",
547 | "start_date": "2015-10-17T11:23:46Z",
548 | "start_date_local": "2015-10-17T13:23:46Z",
549 | "timezone": "(GMT+01:00) Europe\/Zurich",
550 | "start_latlng": [
551 | 46.63,
552 | 6.7
553 | ],
554 | "end_latlng": [
555 | 46.63,
556 | 6.7
557 | ],
558 | "location_city": "Villars-Tiercelin",
559 | "location_state": "Vaud",
560 | "location_country": "Switzerland",
561 | "start_latitude": 46.63,
562 | "start_longitude": 6.7,
563 | "achievement_count": 10,
564 | "kudos_count": 1,
565 | "comment_count": 0,
566 | "athlete_count": 1,
567 | "photo_count": 0,
568 | "map": {
569 | "id": "a414736695",
570 | "summary_polyline": "avq{Gij|g@jNXpMoFxc@nJlQcPgG_MqH|EoId@wa@gMeEaHbOaN_HuJsGyCyDhAcJpW{EyFx@cDiEcFD{BuZoIaGoL}HoF_Fs@aIlEeD}@yRiXoKtOrIfIlPpDpHgChKzL|R`I~MER}CpIpCrCvBe@`Ht@~HdHhDvEcLhKlOpOyMdCpHtEfBdKi^jBef@pCuCpBcJbG|@lMtJzMfUTrE}A`DiMwDkEjBf@_HaA_B{SsKkC|`@qDhJ_LcH}EZoEuEmMo@mDr@AbBkDoAcEfMdHnHnAgBzQbXla@`M|GDtJmG|FhNcL|I{@iGuLuHqa@kMkRaXeEbL}FoEUtDiAeBaAdD_AtHbHrLi@nFaFkDyCdAdY`QvDiAgHJ",
571 | "resource_state": 2
572 | },
573 | "trainer": false,
574 | "commute": false,
575 | "manual": false,
576 | "private": false,
577 | "flagged": false,
578 | "gear_id": null,
579 | "average_speed": 3.272,
580 | "max_speed": 5,
581 | "total_photo_count": 0,
582 | "has_kudoed": false,
583 | "workout_type": null
584 | },
585 | {
586 | "id": 412842027,
587 | "resource_state": 2,
588 | "external_id": "runtastic_20151014_1137_Running.gpx",
589 | "upload_id": 462484115,
590 | "athlete": {
591 | "id": 11730283,
592 | "resource_state": 1
593 | },
594 | "name": "semi-marathon (de) sauvage\u00a0!!!",
595 | "distance": 21994.1,
596 | "moving_time": 6625,
597 | "elapsed_time": 6634,
598 | "total_elevation_gain": 151,
599 | "type": "Run",
600 | "start_date": "2015-10-14T09:45:49Z",
601 | "start_date_local": "2015-10-14T11:45:49Z",
602 | "timezone": "(GMT+01:00) Europe\/Zurich",
603 | "start_latlng": [
604 | 46.42,
605 | 6.27
606 | ],
607 | "end_latlng": [
608 | 46.42,
609 | 6.27
610 | ],
611 | "location_city": "Gland",
612 | "location_state": "Vaud",
613 | "location_country": "Switzerland",
614 | "start_latitude": 46.42,
615 | "start_longitude": 6.27,
616 | "achievement_count": 6,
617 | "kudos_count": 1,
618 | "comment_count": 0,
619 | "athlete_count": 2,
620 | "photo_count": 0,
621 | "map": {
622 | "id": "a412842027",
623 | "summary_polyline": "qzhzGmcge@vPbO|C{DbHlKiFnGjH~FArA}EbCmFhPlB`E`@tItJ|DgChQ_Al@@pI|CrJvLzt@{@vV|B`JhIrA?tGtFfFv@`CcBlBpDjKfQjTzBcAdBxB|FqH~N`D|B{AxO_\\xGfKoEzH`N|a@kBpD~F|Hxi@hj@tA{@gAmC~CaBfB|EtCA_AuJhP|FfEfHeEhQfHbGhHwPtDeAsJsc@aHcS_AyTvCyL~KwRfDz@pOa`@eW_UaVig@wNgMiDgHcUgI}`@ki@\\aAeGaNaD_Zd@mMxHyDjAcJgEaNeLjFsFuGyGiCXcBvJaFiEOaBeFoFxFmGjAz@eG`FuCbC_FuAqF_N`BfGaLEsViBcFoH^eWtd@qRgBkGlNeHkG_QhR}GtNcBFoD|E",
624 | "resource_state": 2
625 | },
626 | "trainer": false,
627 | "commute": false,
628 | "manual": false,
629 | "private": false,
630 | "flagged": false,
631 | "gear_id": null,
632 | "average_speed": 3.32,
633 | "max_speed": 4.7,
634 | "total_photo_count": 0,
635 | "has_kudoed": false,
636 | "workout_type": null
637 | },
638 | {
639 | "id": 411499483,
640 | "resource_state": 2,
641 | "external_id": "runtastic_20151012_1211_Running.gpx",
642 | "upload_id": 461097900,
643 | "athlete": {
644 | "id": 11730283,
645 | "resource_state": 1
646 | },
647 | "name": "new record 10 km 49 minutes",
648 | "distance": 10177.7,
649 | "moving_time": 2987,
650 | "elapsed_time": 2987,
651 | "total_elevation_gain": 59.5,
652 | "type": "Run",
653 | "start_date": "2015-10-12T11:21:46Z",
654 | "start_date_local": "2015-10-12T13:21:46Z",
655 | "timezone": "(GMT+01:00) Europe\/Zurich",
656 | "start_latlng": [
657 | 46.42,
658 | 6.27
659 | ],
660 | "end_latlng": [
661 | 46.42,
662 | 6.27
663 | ],
664 | "location_city": "Gland",
665 | "location_state": "Vaud",
666 | "location_country": "Switzerland",
667 | "start_latitude": 46.42,
668 | "start_longitude": 6.27,
669 | "achievement_count": 5,
670 | "kudos_count": 0,
671 | "comment_count": 0,
672 | "athlete_count": 1,
673 | "photo_count": 0,
674 | "map": {
675 | "id": "a411499483",
676 | "summary_polyline": "i}hzGqgge@rIjItDsFbQzViFtGhFbEnQkZlE}DbDFbB}IhM~@hAoIvHWxCqIfH~JdQ`E~InIfLuFkEiK|DnKqK`FoOsKP_BtJ_FmDGwByFsFbG{FfAf@_HiDwCrEtDnHoJu@}F{MvB|BaH_PhCmGq@iAdJoLzVuC_G{Ck@kRqTyTpXgDrIrC~EcChFyAyBxBeFg@mAcC`P{EaFNsC",
677 | "resource_state": 2
678 | },
679 | "trainer": false,
680 | "commute": false,
681 | "manual": false,
682 | "private": false,
683 | "flagged": false,
684 | "gear_id": null,
685 | "average_speed": 3.407,
686 | "max_speed": 7.9,
687 | "total_photo_count": 0,
688 | "has_kudoed": false,
689 | "workout_type": 0
690 | },
691 | {
692 | "id": 411499492,
693 | "resource_state": 2,
694 | "external_id": "runtastic_20151010_1600_Running.gpx",
695 | "upload_id": 461097899,
696 | "athlete": {
697 | "id": 11730283,
698 | "resource_state": 1
699 | },
700 | "name": "new record 15 km 1:19",
701 | "distance": 15126.9,
702 | "moving_time": 4768,
703 | "elapsed_time": 4768,
704 | "total_elevation_gain": 265.4,
705 | "type": "Run",
706 | "start_date": "2015-10-10T14:41:06Z",
707 | "start_date_local": "2015-10-10T16:41:06Z",
708 | "timezone": "(GMT+01:00) Europe\/Zurich",
709 | "start_latlng": [
710 | 46.63,
711 | 6.7
712 | ],
713 | "end_latlng": [
714 | 46.63,
715 | 6.7
716 | ],
717 | "location_city": "Villars-Tiercelin",
718 | "location_state": "Vaud",
719 | "location_country": "Switzerland",
720 | "start_latitude": 46.63,
721 | "start_longitude": 6.7,
722 | "achievement_count": 6,
723 | "kudos_count": 0,
724 | "comment_count": 0,
725 | "athlete_count": 1,
726 | "photo_count": 0,
727 | "map": {
728 | "id": "a411499492",
729 | "summary_polyline": "kvq{G}i|g@dOVxLqFhe@xInQeNt@kCnCKCeC~AyAtHBbUfJfF[`AkDb@rAuGzBkGsAiBsB[sI{IgMkPnCtAdLrCvAuAn@GrCuCBs@lCcIkNqKtHyRwCgUgJkQqWfEoMpCo@jGvCtHtKkNzLwLoNyDjKiARaEyEqAPBsQiZsIeHeNgLcGgLfFlJdLbKdFxK`DvJo@~FvCc@fD|@|CyK`JkDq@_DfIxLlRo\\aHuTiMqJSyDkC~DrCvJXfUxLb`@lIxS`Nu@pC_AW_AkBn@oBfF`D",
730 | "resource_state": 2
731 | },
732 | "trainer": false,
733 | "commute": false,
734 | "manual": false,
735 | "private": false,
736 | "flagged": false,
737 | "gear_id": null,
738 | "average_speed": 3.173,
739 | "max_speed": 4.9,
740 | "total_photo_count": 0,
741 | "has_kudoed": false,
742 | "workout_type": 0
743 | },
744 | {
745 | "id": 411499491,
746 | "resource_state": 2,
747 | "external_id": "runtastic_20151008_1147_Running.gpx",
748 | "upload_id": 461097895,
749 | "athlete": {
750 | "id": 11730283,
751 | "resource_state": 1
752 | },
753 | "name": "new record 15 km",
754 | "distance": 14993.6,
755 | "moving_time": 4841,
756 | "elapsed_time": 4872,
757 | "total_elevation_gain": 131.2,
758 | "type": "Run",
759 | "start_date": "2015-10-08T10:25:54Z",
760 | "start_date_local": "2015-10-08T12:25:54Z",
761 | "timezone": "(GMT+01:00) Europe\/Zurich",
762 | "start_latlng": [
763 | 46.42,
764 | 6.27
765 | ],
766 | "end_latlng": [
767 | 46.42,
768 | 6.27
769 | ],
770 | "location_city": "Gland",
771 | "location_state": "Vaud",
772 | "location_country": "Switzerland",
773 | "start_latitude": 46.42,
774 | "start_longitude": 6.27,
775 | "achievement_count": 0,
776 | "kudos_count": 0,
777 | "comment_count": 0,
778 | "athlete_count": 1,
779 | "photo_count": 0,
780 | "map": {
781 | "id": "a411499491",
782 | "summary_polyline": "_|hzGyege@{A~BsMaORsFkOmWgPaa@_BsFdHeGiGaR}AR{AeEkF`CnCtQoHiSqBgJGkHdLuInEjHj@iKuBaHyJ|HoBGoCrEuEhAaE}CeEsKpFqJtCtBjEqGz@vCeAgDlEuE|BhEl@`MiC~B~DkD@qC|@jBt@gGoDdNoMxG_I^gD}HlC}FsCfFjK`R~@pRKbh@zGqFrAxDuCvIzZ~YfH{KcOw^sCn@zAkCyAhA}@kBAqG}CkMpFuCzAjEnAw@|GzRlOaNpBbF{@lDvExId@zP}]tm@|CzFtBqAfJhR|FcJfCJo@qCdAiIzEgHhJ~QxCzBuEbH",
783 | "resource_state": 2
784 | },
785 | "trainer": false,
786 | "commute": false,
787 | "manual": false,
788 | "private": false,
789 | "flagged": false,
790 | "gear_id": null,
791 | "average_speed": 3.097,
792 | "max_speed": 4.7,
793 | "total_photo_count": 0,
794 | "has_kudoed": false,
795 | "workout_type": 0
796 | },
797 | {
798 | "id": 411499484,
799 | "resource_state": 2,
800 | "external_id": "runtastic_20151005_1117_Running.gpx",
801 | "upload_id": 461097894,
802 | "athlete": {
803 | "id": 11730283,
804 | "resource_state": 1
805 | },
806 | "name": "La C\u00e9zille avec Seb",
807 | "distance": 13678.6,
808 | "moving_time": 4455,
809 | "elapsed_time": 4464,
810 | "total_elevation_gain": 208.2,
811 | "type": "Run",
812 | "start_date": "2015-10-05T10:02:17Z",
813 | "start_date_local": "2015-10-05T12:02:17Z",
814 | "timezone": "(GMT+01:00) Europe\/Zurich",
815 | "start_latlng": [
816 | 46.42,
817 | 6.27
818 | ],
819 | "end_latlng": [
820 | 46.42,
821 | 6.27
822 | ],
823 | "location_city": "Gland",
824 | "location_state": "Vaud",
825 | "location_country": "Switzerland",
826 | "start_latitude": 46.42,
827 | "start_longitude": 6.27,
828 | "achievement_count": 2,
829 | "kudos_count": 0,
830 | "comment_count": 0,
831 | "athlete_count": 2,
832 | "photo_count": 0,
833 | "map": {
834 | "id": "a411499484",
835 | "summary_polyline": "a|hzGcege@rQ`P~CuDtHvKiFvGfHtFErAuEhCaAfF_G|Io@bF}BwF_FlFwBKgD|HkFBm@xCuDWkD_FwF`L_Bh@{AiCsER{AkBgDvFgBOiFxH_@`DmHdDuEpQeC^uAqDmBpFaGBkKxMaGsA}GfHoCgAoR|JgCeCcMr[pAvB[|EaB|EmHdIStCrOzCMqFzD{QtNqVvFw@zBqDjE[tGkG~J~BtGuBvCqEnKoCtFoMdJ{KJoHvCo@jKyLoDsIb@eAbBtBxEMlA`C~JcNfFjHfBy@X_CfGw@nCaGjBThFaG~BtFzJ}W|EwEeHqG~EuF}HiLiCpEcRaQ",
836 | "resource_state": 2
837 | },
838 | "trainer": false,
839 | "commute": false,
840 | "manual": false,
841 | "private": false,
842 | "flagged": false,
843 | "gear_id": null,
844 | "average_speed": 3.07,
845 | "max_speed": 5.5,
846 | "total_photo_count": 0,
847 | "has_kudoed": false,
848 | "workout_type": 0
849 | },
850 | {
851 | "id": 411499482,
852 | "resource_state": 2,
853 | "external_id": "runtastic_20151003_1152_Running.gpx",
854 | "upload_id": 461097890,
855 | "athlete": {
856 | "id": 11730283,
857 | "resource_state": 1
858 | },
859 | "name": "10 km 52 minutes",
860 | "distance": 10462.3,
861 | "moving_time": 3304,
862 | "elapsed_time": 3304,
863 | "total_elevation_gain": 145,
864 | "type": "Run",
865 | "start_date": "2015-10-03T10:56:42Z",
866 | "start_date_local": "2015-10-03T12:56:42Z",
867 | "timezone": "(GMT+01:00) Europe\/Zurich",
868 | "start_latlng": [
869 | 46.63,
870 | 6.7
871 | ],
872 | "end_latlng": [
873 | 46.63,
874 | 6.7
875 | ],
876 | "location_city": "Villars-Tiercelin",
877 | "location_state": "Vaud",
878 | "location_country": "Switzerland",
879 | "start_latitude": 46.63,
880 | "start_longitude": 6.7,
881 | "achievement_count": 1,
882 | "kudos_count": 0,
883 | "comment_count": 0,
884 | "athlete_count": 1,
885 | "photo_count": 0,
886 | "map": {
887 | "id": "a411499482",
888 | "summary_polyline": "svq{Gii|g@nODtLuFze@tIlOqOsG{LkIrFaHVqa@gMiRoXxDmL~CaAjIhEvFhJeObM_L}NeE~KuFoE{@|@b@jBsAuAaCtMjHfMw@zEaFmDkEv@w_@oIsUkMoJ[cEkClEtClJZxRdLlMzQxHtBvG_KtG[dI`HKrHnEtBR_D{TiNjUhO",
889 | "resource_state": 2
890 | },
891 | "trainer": false,
892 | "commute": false,
893 | "manual": false,
894 | "private": false,
895 | "flagged": false,
896 | "gear_id": null,
897 | "average_speed": 3.167,
898 | "max_speed": 4.2,
899 | "total_photo_count": 0,
900 | "has_kudoed": false,
901 | "workout_type": 0
902 | },
903 | {
904 | "id": 411499479,
905 | "resource_state": 2,
906 | "external_id": "runtastic_20151001_1053_Running.gpx",
907 | "upload_id": 461097888,
908 | "athlete": {
909 | "id": 11730283,
910 | "resource_state": 1
911 | },
912 | "name": "10 km 51 minutes avec seb et gaetan",
913 | "distance": 10121.2,
914 | "moving_time": 3102,
915 | "elapsed_time": 3106,
916 | "total_elevation_gain": 61.6,
917 | "type": "Run",
918 | "start_date": "2015-10-01T10:01:12Z",
919 | "start_date_local": "2015-10-01T12:01:12Z",
920 | "timezone": "(GMT+01:00) Europe\/Zurich",
921 | "start_latlng": [
922 | 46.42,
923 | 6.27
924 | ],
925 | "end_latlng": [
926 | 46.42,
927 | 6.27
928 | ],
929 | "location_city": "Gland",
930 | "location_state": "Vaud",
931 | "location_country": "Switzerland",
932 | "start_latitude": 46.42,
933 | "start_longitude": 6.27,
934 | "achievement_count": 1,
935 | "kudos_count": 0,
936 | "comment_count": 0,
937 | "athlete_count": 3,
938 | "photo_count": 0,
939 | "map": {
940 | "id": "a411499479",
941 | "summary_polyline": "m{hzGudge@vP~OxDkDhHpKsFbGhFbEtHiOtCm@hCsHpEuDpCLpB_JdCw@`JnCz|@ziAhBsD_DmVRoMvIeF|@aIyD{NqLjFgF}GcHeChKiHcE{@uAqEyFvF_G|@p@oFhF}CjBkE}@yFyM|ArFmLZsPgB}J{Bq@cIvD_St`@_SqBaGtNaHuGgMdMsKnSwGvF",
942 | "resource_state": 2
943 | },
944 | "trainer": false,
945 | "commute": false,
946 | "manual": false,
947 | "private": false,
948 | "flagged": false,
949 | "gear_id": null,
950 | "average_speed": 3.263,
951 | "max_speed": 4.4,
952 | "total_photo_count": 0,
953 | "has_kudoed": false,
954 | "workout_type": 0
955 | },
956 | {
957 | "id": 411499487,
958 | "resource_state": 2,
959 | "external_id": "runtastic_20150928_1109_Running.gpx",
960 | "upload_id": 461097887,
961 | "athlete": {
962 | "id": 11730283,
963 | "resource_state": 1
964 | },
965 | "name": "new record 10 km",
966 | "distance": 10147.2,
967 | "moving_time": 3381,
968 | "elapsed_time": 3390,
969 | "total_elevation_gain": 79,
970 | "type": "Run",
971 | "start_date": "2015-09-28T10:12:17Z",
972 | "start_date_local": "2015-09-28T12:12:17Z",
973 | "timezone": "(GMT+01:00) Europe\/Zurich",
974 | "start_latlng": [
975 | 46.42,
976 | 6.27
977 | ],
978 | "end_latlng": [
979 | 46.42,
980 | 6.27
981 | ],
982 | "location_city": "Gland",
983 | "location_state": "Vaud",
984 | "location_country": "Switzerland",
985 | "start_latitude": 46.42,
986 | "start_longitude": 6.27,
987 | "achievement_count": 0,
988 | "kudos_count": 0,
989 | "comment_count": 0,
990 | "athlete_count": 1,
991 | "photo_count": 0,
992 | "map": {
993 | "id": "a411499487",
994 | "summary_polyline": "q|hzGafge@jIdHhDoFfQzV}EfF|ElFvQyZdEqD~CFlByIvLfAbAqIhLgBO}D|JjI`K|A{BvKkMoEuE{O|AyCeAo@v@r@qA|Bf@rBeLjBq@vIoIcCgCfAaBtIqCSuEhEwCnH}BReI|OfAt@YzAgEdCkFdOcGJ{FbGtFaFrGeAbFqN|EsEaHaG`FgHiOiTqAy@cDrF_G{FoAhCzF~EvCsDyAcChBmDfAfCq@fAqB_BcBdJgFkFrAeBvDxB`BpB_BlC_G}FPkD",
995 | "resource_state": 2
996 | },
997 | "trainer": false,
998 | "commute": false,
999 | "manual": false,
1000 | "private": false,
1001 | "flagged": false,
1002 | "gear_id": null,
1003 | "average_speed": 3.001,
1004 | "max_speed": 4.6,
1005 | "total_photo_count": 0,
1006 | "has_kudoed": false,
1007 | "workout_type": 0
1008 | },
1009 | {
1010 | "id": 411499473,
1011 | "resource_state": 2,
1012 | "external_id": "runtastic_20150926_1642_Running.gpx",
1013 | "upload_id": 461097886,
1014 | "athlete": {
1015 | "id": 11730283,
1016 | "resource_state": 1
1017 | },
1018 | "name": "6 km 30 minutes",
1019 | "distance": 6013.1,
1020 | "moving_time": 1813,
1021 | "elapsed_time": 1817,
1022 | "total_elevation_gain": 97.1,
1023 | "type": "Run",
1024 | "start_date": "2015-09-26T16:11:46Z",
1025 | "start_date_local": "2015-09-26T18:11:46Z",
1026 | "timezone": "(GMT+01:00) Europe\/Zurich",
1027 | "start_latlng": [
1028 | 46.63,
1029 | 6.7
1030 | ],
1031 | "end_latlng": [
1032 | 46.63,
1033 | 6.7
1034 | ],
1035 | "location_city": "Villars-Tiercelin",
1036 | "location_state": "Vaud",
1037 | "location_country": "Switzerland",
1038 | "start_latitude": 46.63,
1039 | "start_longitude": 6.7,
1040 | "achievement_count": 6,
1041 | "kudos_count": 0,
1042 | "comment_count": 0,
1043 | "athlete_count": 1,
1044 | "photo_count": 0,
1045 | "map": {
1046 | "id": "a411499473",
1047 | "summary_polyline": "uvq{Gii|g@nOLvLuF|d@tIjP}OmGaMmLzHcRsCoUiJwQ_X`EmLjC_ApIzDvFtJaOlMyKiOuEhLcFoE{@x@b@hBwAsA_CjLdHhNa@tE`BdFlEnD",
1048 | "resource_state": 2
1049 | },
1050 | "trainer": false,
1051 | "commute": false,
1052 | "manual": false,
1053 | "private": false,
1054 | "flagged": false,
1055 | "gear_id": null,
1056 | "average_speed": 3.317,
1057 | "max_speed": 4.7,
1058 | "total_photo_count": 0,
1059 | "has_kudoed": false,
1060 | "workout_type": 0
1061 | },
1062 | {
1063 | "id": 411499471,
1064 | "resource_state": 2,
1065 | "external_id": "runtastic_20150925_1301_Running.gpx",
1066 | "upload_id": 461097885,
1067 | "athlete": {
1068 | "id": 11730283,
1069 | "resource_state": 1
1070 | },
1071 | "name": "6 km 29 minutes",
1072 | "distance": 5931,
1073 | "moving_time": 1771,
1074 | "elapsed_time": 1771,
1075 | "total_elevation_gain": 97.8,
1076 | "type": "Run",
1077 | "start_date": "2015-09-25T12:31:19Z",
1078 | "start_date_local": "2015-09-25T14:31:19Z",
1079 | "timezone": "(GMT+01:00) Europe\/Zurich",
1080 | "start_latlng": [
1081 | 46.63,
1082 | 6.7
1083 | ],
1084 | "end_latlng": [
1085 | 46.63,
1086 | 6.7
1087 | ],
1088 | "location_city": "Villars-Tiercelin",
1089 | "location_state": "Vaud",
1090 | "location_country": "Switzerland",
1091 | "start_latitude": 46.63,
1092 | "start_longitude": 6.7,
1093 | "achievement_count": 6,
1094 | "kudos_count": 0,
1095 | "comment_count": 0,
1096 | "athlete_count": 1,
1097 | "photo_count": 0,
1098 | "map": {
1099 | "id": "a411499471",
1100 | "summary_polyline": "}qq{Ggh|g@jKy@fLcFve@hIhOmOmGwLiKjG_G?ga@gMyQeXzDsLpCcAzHnDhGhKeOjMsKgOuEdLmGkEFjDcBeAmBrKbH`N_@tFfB`FrFrD",
1101 | "resource_state": 2
1102 | },
1103 | "trainer": false,
1104 | "commute": false,
1105 | "manual": false,
1106 | "private": false,
1107 | "flagged": false,
1108 | "gear_id": null,
1109 | "average_speed": 3.349,
1110 | "max_speed": 5.1,
1111 | "total_photo_count": 0,
1112 | "has_kudoed": false,
1113 | "workout_type": 0
1114 | },
1115 | {
1116 | "id": 411499472,
1117 | "resource_state": 2,
1118 | "external_id": "runtastic_20150919_1606_Running.gpx",
1119 | "upload_id": 461097884,
1120 | "athlete": {
1121 | "id": 11730283,
1122 | "resource_state": 1
1123 | },
1124 | "name": "6 km 32 minutes",
1125 | "distance": 6052.5,
1126 | "moving_time": 1944,
1127 | "elapsed_time": 1955,
1128 | "total_elevation_gain": 99,
1129 | "type": "Run",
1130 | "start_date": "2015-09-19T15:33:55Z",
1131 | "start_date_local": "2015-09-19T17:33:55Z",
1132 | "timezone": "(GMT+01:00) Europe\/Zurich",
1133 | "start_latlng": [
1134 | 46.63,
1135 | 6.7
1136 | ],
1137 | "end_latlng": [
1138 | 46.63,
1139 | 6.7
1140 | ],
1141 | "location_city": "Villars-Tiercelin",
1142 | "location_state": "Vaud",
1143 | "location_country": "Switzerland",
1144 | "start_latitude": 46.63,
1145 | "start_longitude": 6.7,
1146 | "achievement_count": 4,
1147 | "kudos_count": 0,
1148 | "comment_count": 0,
1149 | "athlete_count": 1,
1150 | "photo_count": 0,
1151 | "map": {
1152 | "id": "a411499472",
1153 | "summary_polyline": "evq{Gsj|g@dCzAnJq@zL{Fnd@dJzPeOaGsM_HnFoK\\o`@oM_RmXfEuL~By@dIdDfGhK_OtM_LaOcE|KoFqE}@lA`@hBwAwAwBnL`HvM]lFdB`F~E~D",
1154 | "resource_state": 2
1155 | },
1156 | "trainer": false,
1157 | "commute": false,
1158 | "manual": false,
1159 | "private": false,
1160 | "flagged": false,
1161 | "gear_id": null,
1162 | "average_speed": 3.113,
1163 | "max_speed": 4.2,
1164 | "total_photo_count": 0,
1165 | "has_kudoed": false,
1166 | "workout_type": 0
1167 | }
1168 | ]
1169 |
--------------------------------------------------------------------------------