├── .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 | ![Strava Visualization](https://raw.githubusercontent.com/nst/STStrava/master/activities.gif) 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 | --------------------------------------------------------------------------------