├── .gitignore ├── monkey.jungle ├── resources ├── drawables │ ├── logo.png │ ├── launcher_icon.png │ └── drawables.xml ├── layouts │ └── layout.xml └── strings │ └── strings.xml ├── source ├── Version.mc ├── Config.mc.template ├── DownloadStatus.mc ├── Urls.mc ├── MessageView.mc ├── TaoGlanceView.mc ├── NetUtil.mc ├── GrantView.mc ├── DownloadView.mc ├── WorkoutView.mc ├── DeferredIntent.mc ├── RequestDelegate.mc ├── TrainAsONEApp.mc ├── MessageUtil.mc ├── Grant.mc ├── WorkoutFormatter.mc ├── DownloadRequest.mc ├── WorkoutDelegate.mc └── TaoModel.mc ├── LICENSE ├── koko ├── manifest-downloadcapable.xml ├── README.md ├── manifest.xml ├── manifest-beta.xml └── manifest-allwatches.xml /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | source/Config.mc 3 | -------------------------------------------------------------------------------- /monkey.jungle: -------------------------------------------------------------------------------- 1 | project.manifest = manifest.xml 2 | 3 | -------------------------------------------------------------------------------- /resources/drawables/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrainAsONE/trainasone-connectiq/HEAD/resources/drawables/logo.png -------------------------------------------------------------------------------- /resources/drawables/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TrainAsONE/trainasone-connectiq/HEAD/resources/drawables/launcher_icon.png -------------------------------------------------------------------------------- /source/Version.mc: -------------------------------------------------------------------------------- 1 | // Must be in the form X.Y.Z where X Y & Z are all digits. No letters or other characters 2 | const AppVersion = "0.42.0"; 3 | -------------------------------------------------------------------------------- /resources/layouts/layout.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /resources/drawables/drawables.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | -------------------------------------------------------------------------------- /source/Config.mc.template: -------------------------------------------------------------------------------- 1 | import Toybox.Lang; 2 | 3 | // Contact garmin@trainasone.com to ask for a ClientId and ClientSecret 4 | const ClientId = "YourClientId"; 5 | const ClientSecret = "YourClientSecret"; 6 | const ServerUrls = ["https://beta.trainasone.com"] as Array; 7 | const ExcludeViewStackWorkaroundPreMonkeyV3 = "58f5139d5df474fd8ba05cf6c60c85840199f04f"; // Enable for physical devices, disable for simulator with this uniqueIdentifier -------------------------------------------------------------------------------- /source/DownloadStatus.mc: -------------------------------------------------------------------------------- 1 | (:glance) 2 | module DownloadStatus { 3 | enum { 4 | OK = 0, 5 | INSUFFICIENT_SUBSCRIPTION_CAPABILITIES = 1, 6 | DEVICE_DOES_NOT_SUPPORT_DOWNLOAD = 2, 7 | EXTERNAL_SCHEDULE = 3, 8 | NO_WORKOUT_AVAILABLE = 4, 9 | WORKOUT_NOT_DOWNLOAD_CAPABLE = 5, 10 | RESPONSE_MISSING_WORKOUT_DATA = 6, 11 | RESPONSE_CODE_ZERO = 7, 12 | DOWNLOAD_TIMEOUT = 8, 13 | NOT_YET_ATTEMPTED = 9, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /source/Urls.mc: -------------------------------------------------------------------------------- 1 | module Urls { 2 | const ABOUT_URL = "https://iq.trainasone.com/clwd"; 3 | const NOT_DOWNLOAD_CAPABLE = "https://iq.trainasone.com/ndc"; 4 | const NOT_DOWNLOAD_NOT_SUPPORTED = "https://iq.trainasone.com/dns"; 5 | const INSUFFICIENT_SUBSCRIPTION_CAPABILITIES = 6 | "https://iq.trainasone.com/isc"; 7 | const CANNOT_LOAD_WORKOUT_DATA = "https://iq.trainasone.com/clwd"; 8 | const DOWNLOAD_TIMEOUT = "https://iq.trainasone.com/dt"; 9 | } 10 | -------------------------------------------------------------------------------- /source/MessageView.mc: -------------------------------------------------------------------------------- 1 | import Toybox.Graphics; 2 | import Toybox.Lang; 3 | import Toybox.System; 4 | import Toybox.WatchUi; 5 | 6 | // Show error message 7 | class MessageView extends WatchUi.View { 8 | private var _message as String; 9 | 10 | function initialize(message as String) { 11 | _message = message; 12 | Application.getApp().log("message: " + _message); 13 | View.initialize(); 14 | } 15 | 16 | // Should allow a menu/select to restart main loop 17 | function onLayout(dc as Graphics.Dc) as Void { 18 | setLayout(Rez.Layouts.StandardLayout(dc)); 19 | (View.findDrawableById("message") as WatchUi.Text).setText(_message); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /source/TaoGlanceView.mc: -------------------------------------------------------------------------------- 1 | import Toybox.Graphics; 2 | import Toybox.Lang; 3 | import Toybox.WatchUi; 4 | 5 | (:glance) 6 | class TaoGlanceView extends WatchUi.GlanceView { 7 | var _workoutText as String?; 8 | 9 | function initialize() { 10 | GlanceView.initialize(); 11 | } 12 | 13 | function onLayout(dc as Graphics.Dc) as Void { 14 | // FIXME - adjust formatting to merge lines 2 & 3 for glance view 15 | _workoutText = new WorkoutFormatter().buildMessageFromWorkout(); 16 | // Application.getApp().log("workoutText(" + _workoutText + ")"); 17 | } 18 | 19 | function onUpdate(dc as Graphics.Dc) as Void { 20 | if (_workoutText != null) { 21 | dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_BLACK); 22 | dc.drawText( 23 | 0, 24 | 0, 25 | Graphics.FONT_XTINY, 26 | _workoutText as String, 27 | Graphics.TEXT_JUSTIFY_LEFT 28 | ); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /source/NetUtil.mc: -------------------------------------------------------------------------------- 1 | import Toybox.Application; 2 | import Toybox.Communications; 3 | import Toybox.Lang; 4 | import Toybox.System; 5 | import Toybox.WatchUi; 6 | 7 | class NetUtil { 8 | public static function extractResponseCode( 9 | responseCode as Number, 10 | data as Dictionary or String or Null 11 | ) as Number { 12 | // workaround non 200 response codes being flattened out by Garmin runtime 13 | if (responseCode == 200 && data != null && data["responseCode"] != null) { 14 | return data["responseCode"] as Number; 15 | } 16 | return responseCode; 17 | } 18 | 19 | public static function deviceParams() as Dictionary { 20 | var deviceSettings = System.getDeviceSettings(); 21 | var appVersion = Application.getApp().appVersion(); 22 | var device = 23 | deviceSettings.partNumber + 24 | Lang.format("/$1$.$2$.$3$", deviceSettings.monkeyVersion); 25 | 26 | return { 27 | "appVersion" => appVersion, 28 | "device" => device, 29 | }; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /source/GrantView.mc: -------------------------------------------------------------------------------- 1 | import Toybox.Graphics; 2 | import Toybox.Lang; 3 | import Toybox.WatchUi; 4 | 5 | class GrantDelegate extends WatchUi.BehaviorDelegate { 6 | function initialize() { 7 | BehaviorDelegate.initialize(); 8 | } 9 | } 10 | 11 | class GrantView extends WatchUi.View { 12 | private var _request as GrantRequest?; 13 | private var _reAuth as Boolean; 14 | private var _clearAuth as Boolean; 15 | 16 | function initialize(reAuth as Boolean, clearAuth as Boolean) { 17 | View.initialize(); 18 | _reAuth = reAuth; 19 | _clearAuth = clearAuth; 20 | } 21 | 22 | function onLayout(dc as Graphics.Dc) as Void { 23 | setLayout(Rez.Layouts.StandardLayout(dc)); 24 | var message = 25 | WatchUi.loadResource( 26 | _reAuth ? Rez.Strings.grantReAuthString : Rez.Strings.grantString 27 | ) as String; 28 | var view = View.findDrawableById("message") as WatchUi.Text; 29 | view.setFont(Graphics.FONT_SMALL); 30 | view.setText(message); 31 | } 32 | 33 | function onShow() as Void { 34 | if (_request == null) { 35 | _request = new GrantRequest( 36 | new GrantRequestDelegate(_clearAuth), 37 | _clearAuth 38 | ); 39 | _request.start(); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /source/DownloadView.mc: -------------------------------------------------------------------------------- 1 | import Toybox.Graphics; 2 | import Toybox.Lang; 3 | import Toybox.WatchUi; 4 | 5 | class DownloadDelegate extends WatchUi.BehaviorDelegate { 6 | function initialize() { 7 | BehaviorDelegate.initialize(); 8 | } 9 | } 10 | 11 | class DownloadView extends WatchUi.View { 12 | private var _message as String?; 13 | private var _stateText as String = ""; 14 | private var _request as DownloadRequest?; 15 | 16 | function initialize(message as String?) { 17 | View.initialize(); 18 | _message = message != null ? message : "Checking workout"; 19 | } 20 | 21 | function onLayout(dc as Graphics.Dc) as Void { 22 | setLayout(Rez.Layouts.StandardLayout(dc)); 23 | } 24 | 25 | function onShow() as Void { 26 | if (_request == null) { 27 | updateState("connecting"); 28 | _request = new DownloadRequest(self); 29 | _request.start(); 30 | } 31 | } 32 | 33 | function updateState(stateText as String) as Void { 34 | _stateText = stateText; 35 | Application.getApp().log("download: " + _message + ": " + _stateText); 36 | (View.findDrawableById("message") as WatchUi.Text).setText( 37 | _message + "\n(" + _stateText + ")" 38 | ); 39 | WatchUi.requestUpdate(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /source/WorkoutView.mc: -------------------------------------------------------------------------------- 1 | import Toybox.Application; 2 | import Toybox.Lang; 3 | import Toybox.Graphics; 4 | import Toybox.WatchUi; 5 | 6 | class WorkoutView extends WatchUi.View { 7 | function initialize() { 8 | View.initialize(); 9 | } 10 | 11 | function onLayout(dc as Graphics.Dc) as Void { 12 | setLayout(Rez.Layouts.StandardLayout(dc)); 13 | // Graphics.Dc.getHeight() fails with "Could not find symbol mHeight", presumably as we have not displayed yet 14 | var deviceSettings = System.getDeviceSettings(); 15 | var height = deviceSettings.screenHeight; 16 | var width = deviceSettings.screenWidth; 17 | var centre = width / 2; 18 | // Application.getApp().log("display: " + width + "x" + height); 19 | var view = View.findDrawableById("message") as WatchUi.Text; 20 | 21 | // Start text higher on vivoactive's shorter screen 22 | view.setLocation(centre, height <= 148 ? 62 : 74); // vivoactive 23 | 24 | // MEDIUM font works better on devices with 215 wide screens (235, 630, 735xt, etc) 25 | // as the SMALL font is much harder to read 26 | view.setFont(width == 215 ? Graphics.FONT_MEDIUM : Graphics.FONT_SMALL); 27 | 28 | var workoutFormatter = new WorkoutFormatter(); 29 | view.setText(workoutFormatter.buildMessageFromWorkout()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /source/DeferredIntent.mc: -------------------------------------------------------------------------------- 1 | // Code from Garmin to workaround System.exitTo(intent) issue in 645 firmware (SDK 3.0.3) 2 | 3 | import Toybox.System; 4 | import Toybox.Timer; 5 | import Toybox.WatchUi; 6 | 7 | class DeferredIntent { 8 | private var _data as Intent; 9 | private var _controller as WorkoutMenuDelegate; 10 | private var _timer as Timer.Timer; 11 | 12 | function initialize(controller as WorkoutMenuDelegate, data as Intent) { 13 | _controller = controller; 14 | _data = data; 15 | _timer = new Timer.Timer(); 16 | _timer.start(method(:onTimer), 200, false); 17 | } 18 | 19 | function onTimer() as Void { 20 | _controller.handleDeferredIntent(_data); 21 | } 22 | } 23 | 24 | /* 25 | // Calling example 26 | public function handleDownloadResponse( data ) { 27 | // Clear the confirmation from the page stack 28 | WatchUi.popView( WatchUi.SLIDE_IMMEDIATE ); 29 | // Kick off the kludge transaction 30 | _activeTransaction = new self.DeferredIntent( self, data ); 31 | } 32 | 33 | // This is fired by the kludge transaction 34 | public function handleIntent( data ) { 35 | _activeTransaction = null; 36 | if ( data != null ) { 37 | System.exitTo( data.toIntent() ); 38 | } else { 39 | handleError( -1000 ); 40 | } 41 | } 42 | */ 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2017, TrainAsONE 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /source/RequestDelegate.mc: -------------------------------------------------------------------------------- 1 | import Toybox.Communications; 2 | import Toybox.Lang; 3 | import Toybox.WatchUi; 4 | 5 | class RequestDelegate { 6 | function handleErrorResponseCode( 7 | action as String, 8 | responseCode as Number 9 | ) as Void { 10 | switch (responseCode) { 11 | case Communications.BLE_ERROR: 12 | case Communications.BLE_HOST_TIMEOUT: 13 | case Communications.BLE_SERVER_TIMEOUT: 14 | case Communications.BLE_NO_DATA: 15 | case Communications.BLE_REQUEST_CANCELLED: 16 | case Communications.BLE_QUEUE_FULL: 17 | case Communications.BLE_REQUEST_TOO_LARGE: 18 | case Communications.BLE_UNKNOWN_SEND_ERROR: 19 | MessageUtil.showErrorMessage( 20 | WatchUi.loadResource(Rez.Strings.errorPhoneConnection) + 21 | " " + 22 | responseCode + 23 | " " + 24 | action 25 | ); 26 | break; 27 | case Communications.BLE_CONNECTION_UNAVAILABLE: 28 | MessageUtil.showErrorResource(Rez.Strings.errorPleaseConnectPhone); 29 | break; 30 | case Communications.NETWORK_REQUEST_TIMED_OUT: 31 | MessageUtil.showErrorResource(Rez.Strings.errorNetworkRequestTimedOut); 32 | case 0: // no data - may be full, or empty FIT returned 33 | MessageUtil.showErrorResource(Rez.Strings.errorNoData); 34 | break; 35 | case 401: // Unauthorized 36 | WatchUi.switchToView( 37 | new GrantView(true, false), 38 | new GrantDelegate(), 39 | WatchUi.SLIDE_IMMEDIATE 40 | ); 41 | break; 42 | case 404: // not found 43 | MessageUtil.showErrorResource(Rez.Strings.errorNotFound); 44 | break; 45 | case 418: // service alternately unavailable 46 | case 503: // service unavailable 47 | MessageUtil.showErrorResource(Rez.Strings.errorServiceUnavailable); 48 | break; 49 | default: 50 | MessageUtil.showErrorMessage( 51 | WatchUi.loadResource(Rez.Strings.serverError) + 52 | " " + 53 | responseCode + 54 | " " + 55 | action 56 | ); 57 | break; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /source/TrainAsONEApp.mc: -------------------------------------------------------------------------------- 1 | import Toybox.Application; 2 | import Toybox.Lang; 3 | import Toybox.System; 4 | import Toybox.WatchUi; 5 | 6 | (:glance) 7 | class TrainAsONEApp extends Application.AppBase { 8 | var model as TaoModel; 9 | 10 | function initialize() { 11 | AppBase.initialize(); 12 | log("Starting TrainAsONE " + appVersion()); 13 | model = new TaoModel(); 14 | } 15 | 16 | function appVersion() as String { 17 | var appVersion = $.AppVersion; 18 | if (Toybox has :PersistedContent) { 19 | appVersion += "+d"; // Download capable 20 | if (PersistedContent has :getAppWorkouts) { 21 | appVersion += ",o"; // Can manage own set of workouts 22 | } 23 | } 24 | return appVersion; 25 | } 26 | 27 | // onStart() is called on application start up 28 | function onStart(state) as Void {} 29 | 30 | // onStop() is called when your application is exiting 31 | function onStop(state) as Void {} 32 | 33 | function getGlanceView() as [ GlanceView ] or [ GlanceView, GlanceViewDelegate ] or Null { 34 | return [new TaoGlanceView()] as [ GlanceView ]; 35 | } 36 | 37 | // Return the initial view of your application here 38 | function getInitialView() as [ Views ] or [ Views, InputDelegates ] { 39 | if (!System.getDeviceSettings().phoneConnected) { 40 | return ( 41 | [ 42 | new MessageView( 43 | WatchUi.loadResource(Rez.Strings.errorPleaseConnectPhone) 44 | ), 45 | new MessageDelegate(null), 46 | ] 47 | ); 48 | } else if (model.accessToken == null) { 49 | return ([new GrantView(false, false), new GrantDelegate()]); 50 | } else { 51 | return ([new DownloadView(null), new DownloadDelegate()]); 52 | } 53 | } 54 | 55 | function log(message as String) as Void { 56 | var now = System.getClockTime(); 57 | System.print( 58 | now.hour.format("%02d") + 59 | ":" + 60 | now.min.format("%02d") + 61 | ":" + 62 | now.sec.format("%02d") + 63 | " " 64 | ); 65 | var array = message.toCharArray(); 66 | for (var i = 0; i < array.size(); ++i) { 67 | if (array[i] == '\n') { 68 | array[i] = ' '; 69 | } 70 | } 71 | System.println(StringUtil.charArrayToString(array)); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /source/MessageUtil.mc: -------------------------------------------------------------------------------- 1 | import Toybox.Application; 2 | import Toybox.Communications; 3 | import Toybox.Lang; 4 | import Toybox.WatchUi; 5 | 6 | /// All of these should be static, but Application.getApp().model does not work in 7 | /// a static method with Type Checking enabled 8 | class MessageUtil { 9 | public static function fullErrorMessage(message as String) as String { 10 | var mModel = Application.getApp().model; 11 | return ( 12 | message + 13 | "\n" + 14 | WatchUi.loadResource( 15 | mModel.downloadIntent != null 16 | ? Rez.Strings.pressForSavedWorkout 17 | : Rez.Strings.pressForOptions 18 | ) 19 | ); 20 | } 21 | 22 | public static function showAbout() as Void { 23 | var message = 24 | WatchUi.loadResource(Rez.Strings.aboutApp) + 25 | Application.getApp().appVersion(); 26 | showMessage(message, Urls.ABOUT_URL); 27 | } 28 | 29 | public static function showErrorResource(rez as ResourceId) as Void { 30 | showErrorResourceWithUrl(rez, null); 31 | } 32 | 33 | public static function showErrorResourceWithUrl(rez as ResourceId, url as String?) as Void { 34 | showErrorMessageWithUrl(WatchUi.loadResource(rez) as String, url); 35 | } 36 | 37 | public static function showErrorMessage(message as String) as Void { 38 | showErrorMessageWithUrl(message, null); 39 | } 40 | 41 | public static function showErrorMessageWithUrl(message as String, url as String?) as Void { 42 | showMessage(fullErrorMessage(message), url); 43 | } 44 | 45 | public static function showMessage(message as String, url as String?) as Void { 46 | WatchUi.switchToView( 47 | new MessageView(message), 48 | new MessageDelegate(url), 49 | WatchUi.SLIDE_IMMEDIATE 50 | ); 51 | } 52 | } 53 | 54 | class MessageDelegate extends WatchUi.BehaviorDelegate { 55 | private var mModel as TaoModel; 56 | private var _url as String?; 57 | 58 | function initialize(url as String?) { 59 | BehaviorDelegate.initialize(); 60 | mModel = Application.getApp().model; 61 | _url = url; 62 | } 63 | 64 | function onMenu() as Boolean { 65 | return showErrorMenu(); 66 | } 67 | 68 | function onSelect() as Boolean { 69 | return showErrorMenu(); 70 | } 71 | 72 | function showErrorMenu() as Boolean { 73 | var menu = new WatchUi.Menu(); 74 | if (_url != null) { 75 | menu.addItem(loadStringResource(Rez.Strings.moreInfo), :moreInfo); 76 | } 77 | if (mModel.hasWorkout()) { 78 | menu.addItem(loadStringResource(Rez.Strings.menuShowSaved), :showSaved); 79 | } 80 | menu.addItem( 81 | loadStringResource(Rez.Strings.menuRetry), 82 | mModel.accessToken == null ? :switchUser : :refetchWorkout 83 | ); 84 | mModel.addStandardMenuOptions(menu); 85 | 86 | WatchUi.pushView(menu, new WorkoutMenuDelegate(_url), WatchUi.SLIDE_UP); 87 | return true; 88 | } 89 | 90 | function loadStringResource(rez as ResourceId) as String { 91 | return WatchUi.loadResource(rez) as String; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /koko: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # koko is a very simple script to automate building targets listed in the manifest 4 | # For those that way inclined it runs fine under Windows 10's Linux Subsystem 5 | # 6 | # Free for any use -- abs@trainasone.com 7 | 8 | APP=trainasone-connectiq 9 | OUTPUTDIR=bin/binaries 10 | MANIFEST=manifest.xml 11 | 12 | while getopts ahlm:nrvy: f; do 13 | case $f in 14 | a) opt_a=1 ;; 15 | h) opt_h=1 ;; 16 | l) opt_l=1 ;; 17 | m) MANIFEST="$OPTARG" ;; 18 | n) opt_n=1 ;; 19 | r) opt_r=-r ;; 20 | y) KOKO_PRIVATE_KEY="$OPTARG" ;; 21 | v) opt_v=1 ;; 22 | esac 23 | done 24 | shift $(expr $OPTIND - 1) 25 | 26 | usage_and_exit() 27 | { 28 | echo "$@" 29 | cat < 2 | TrainAsONE 3 | TrainAsONE beta 4 | TrainAsONE lite 5 | Heart rate 6 | Heart rate for recovery 7 | Heart rate for slow 8 | Speed 9 | «{ 10 | 11 | TrainAsONE Garmin\nworkout app\nv 12 | Temperature adjust 13 | Undulation adjust 14 | Download timeout 15 | Watch workout storage\nfull? Please remove\nworkouts and retry 16 | Sorry, Garmin do not\nsupport download\non your model 17 | Please upgrade to a\nfull TrainAsONE\nsubscription to\ndownload 18 | Workout download\ntimed out 19 | Cannot update\nIs your device full? 20 | This workout cannot\nbe downloaded 21 | Cannot start workouts\nfrom this point 22 | Server returned\n"not found" 23 | Phone connect error 24 | Please connect to\nyour phone to check\nyour next workout 25 | Network request\ntimed out 26 | Server error:\n 27 | Server not available\nplease retry shortly 28 | Unexpected workout\nupdate error 29 | Unexpected workout\ndownload error 30 | Unexpected download\nnot allowed error 31 | Please re-grant access\nto TrainAsONE\non your phone 32 | Please grant access\nto TrainAsONE\non your phone 33 | About 34 | No workout space 35 | Download not supported 36 | Include run back step 37 | Adjust Commitments 38 | Open TrainAsONE 39 | Refetch workout 40 | Retry 41 | Show saved workout 42 | Start workout 43 | Switch TrainAsONE user 44 | Show more info 45 | NO 46 | No data from server 47 | Unable to find\nworkout - Please\ncheck your plan 48 | ( > for options ) 49 | ( > for saved workout ) 50 | Server 51 | Step names 52 | Step target 53 | updated 54 | Updating\nworkout 55 | YES 56 | 57 | today 58 | tomorrow 59 | yesterday 60 | 61 | °C 62 | °F 63 | mm 64 | cm 65 | pc 66 | m 67 | ft 68 | km 69 | mi 70 | min 71 | 72 | $1$\n$2$\n$3$ 73 | No workout available\nplease adjust your\nplan 74 | No workouts while\nyour plan is set to\nexternal schedule 75 | Select to connect\nto TrainAsONE 76 | 77 | 78 | -------------------------------------------------------------------------------- /source/Grant.mc: -------------------------------------------------------------------------------- 1 | import Toybox.Application; 2 | import Toybox.Communications; 3 | import Toybox.Lang; 4 | import Toybox.System; 5 | import Toybox.WatchUi; 6 | 7 | const RedirectUri = "https://localhost"; 8 | const Scope = "WORKOUT"; 9 | 10 | const OAUTH_CODE = "code"; 11 | const OAUTH_ERROR = "error"; 12 | const OAUTH_ERROR_DESCRIPTION = "error_description"; 13 | const HTTP_STATUS_OK = 200; 14 | 15 | // Obtain and store an Oauth2 token for API access 16 | class GrantRequest { 17 | private var mModel as TaoModel; 18 | private var _delegate as GrantRequestDelegate; 19 | private var _clearAuth as Boolean; 20 | 21 | function initialize(delegate as GrantRequestDelegate, clearAuth as Boolean) { 22 | mModel = Application.getApp().model; 23 | _delegate = delegate; 24 | _clearAuth = clearAuth; 25 | Communications.registerForOAuthMessages(method(:handleAccessCodeResult)); // May fire immediately 26 | // Application.getApp().log("Grant(" + clearAuth + ")"); 27 | } 28 | 29 | function start() as Void { 30 | var requestUrl = mModel.serverUrl + "/oauth/authorise"; 31 | var requestParams = 32 | ({ 33 | "client_id" => $.ClientId, 34 | "response_type" => OAUTH_CODE, 35 | "scope" => $.Scope, 36 | "redirect_uri" => $.RedirectUri, 37 | "logout" => _clearAuth ? "1" : "0", 38 | }) as Dictionary; 39 | var resultUrl = $.RedirectUri; 40 | var resultType = Communications.OAUTH_RESULT_TYPE_URL; 41 | // Need to explicitly enumerate the parameters we want to take from the response 42 | var resultKeys = 43 | ({ 44 | OAUTH_CODE => OAUTH_CODE, 45 | OAUTH_ERROR_DESCRIPTION => OAUTH_ERROR_DESCRIPTION, 46 | }) as Dictionary; 47 | Communications.makeOAuthRequest( 48 | requestUrl, 49 | requestParams, 50 | resultUrl, 51 | resultType, 52 | resultKeys 53 | ); 54 | } 55 | 56 | // Callback from Grant attempt 57 | function handleAccessCodeResult(response as OAuthMessage) as Void { 58 | // Application.getApp().log("handleAccessCodeResult(" + response.data + ")"); 59 | 60 | var error; 61 | 62 | // We cannot rely on responseCode here - as simulator gives 200 but at least 735xt real device gives 2! 63 | // So we just check to see if we have a valid code 64 | if (response.data != null) { 65 | var data = response.data as Dictionary; 66 | var code = data[OAUTH_CODE]; 67 | if (code != null) { 68 | // Convert auth code to access token 69 | var url = mModel.serverUrl + "/api/oauth/token"; 70 | var params = 71 | ({ 72 | "client_id" => $.ClientId, 73 | "client_secret" => $.ClientSecret, 74 | "redirect_uri" => $.RedirectUri, 75 | "grant_type" => "authorization_code", 76 | OAUTH_CODE => code, 77 | "jsonErrors" => "true", 78 | }) as Dictionary; 79 | var options = { 80 | :method => Communications.HTTP_REQUEST_METHOD_POST, 81 | }; 82 | Communications.makeWebRequest( 83 | url, 84 | params, 85 | options, 86 | method(:handleAccessTokenResponse) 87 | ); 88 | return; 89 | } else if (data[OAUTH_ERROR_DESCRIPTION] != null) { 90 | error = data[OAUTH_ERROR_DESCRIPTION]; 91 | } else if (response.responseCode != HTTP_STATUS_OK) { 92 | error = "status " + response.responseCode; 93 | } else { 94 | error = "No code returned"; 95 | } 96 | } else { 97 | error = "no data returned"; 98 | } 99 | 100 | MessageUtil.showErrorMessage( 101 | (WatchUi.loadResource(Rez.Strings.serverError) as String) + error 102 | ); 103 | } 104 | 105 | // Handle the token response 106 | function handleAccessTokenResponse( 107 | responseCode as Number, 108 | data as Null or Dictionary or String 109 | ) as Void { 110 | responseCode = NetUtil.extractResponseCode(responseCode, data); 111 | // If we got data back then we were successful. Otherwise 112 | // pass the error onto the delegate 113 | 114 | if (responseCode == HTTP_STATUS_OK) { 115 | if (data == null) { 116 | MessageUtil.showErrorResource(Rez.Strings.noDataFromServer); 117 | } else { 118 | _delegate.handleResponse(data); 119 | } 120 | } else { 121 | _delegate.handleErrorResponseCode("grant", responseCode); 122 | } 123 | } 124 | } 125 | 126 | class GrantRequestDelegate extends RequestDelegate { 127 | private var mModel as TaoModel; 128 | 129 | // Constructor 130 | function initialize(clearAuth as Boolean) { 131 | RequestDelegate.initialize(); 132 | mModel = Application.getApp().model; 133 | if (clearAuth) { 134 | mModel.setAccessToken(null); 135 | } 136 | } 137 | 138 | // Handle a successful response from the server 139 | function handleResponse(data as Dictionary) as Void { 140 | // Store access token 141 | mModel.setAccessToken(data["access_token"]); 142 | // Switch to the data view 143 | WatchUi.switchToView( 144 | new DownloadView(null), 145 | new DownloadDelegate(), 146 | WatchUi.SLIDE_IMMEDIATE 147 | ); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /manifest-downloadcapable.xml: -------------------------------------------------------------------------------- 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 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | eng 122 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # trainasone-connectiq 2 | TrainAsONE Connect IQ app 3 | 4 | This is a simple Connect IQ app to download workouts from the 5 | https://trainasone.com AI running coach. 6 | 7 | It is available in the Garmin Store as 8 | https://apps.garmin.com/en-US/apps/dfbebe0d-1cff-471d-afc0-3cb0be0c89c3 9 | 10 | ## Requirements 11 | - Garmin device or simulator 12 | - ConnectIQ 2.4, PersistedContent and advanced workouts required for workout download 13 | - ClientId and ClientSecret from garmin@trainasone.com 14 | 15 | ## Current functionality 16 | - Authenticate against TrainAsONE server via OAuth2 17 | - Indicate next workout details (including distance & duration) 18 | - Download next workout to device 19 | - Run next planned workout 20 | - Refetch next planned workout 21 | - Login as different TrainAsONE user 22 | - Open TrainAsONE on mobile device 23 | - Set workout step target 24 | - Toggle adjust temperature and undulation and include run back step 25 | 26 | ## Supported devices 27 | 28 | ### Download capable 29 | - Captain Marvel 30 | - D2 Charlie 31 | - D2 Delta 32 | - D2 Delta PX 33 | - D2 Delta S 34 | - D2 Mac1 35 | - Darth Vader 36 | - Descent G1 37 | - Descent Mk1 38 | - Descent Mk2 (glance) 39 | - Descent Mk2s (glance) 40 | - Enduro 41 | - Epix (Gen 2) 42 | - fenix 5 43 | - fenix 5 plus 44 | - fenix 5S 45 | - fenix 5S plus 46 | - fenix 5X 47 | - fenix 5X plus 48 | - fenix 6 (glance) 49 | - fenix 6 Pro (glance) 50 | - fenix 6S (glance) 51 | - fenix 6S Pro (glance) 52 | - fenix 6X Pro (glance) 53 | - fenix 7 (glance) 54 | - fenix 7S (glance) 55 | - fenix 7X (glance) 56 | - fenix Chronos 57 | - First Avenger 58 | - Forerunner 55 (glance) 59 | - Forerunner 245 (glance) 60 | - Forerunner 245 Music (glance) 61 | - Forerunner 255 (glance) 62 | - Forerunner 255 Music (glance) 63 | - Forerunner 255s (glance) 64 | - Forerunner 255s Music (glance) 65 | - Forerunner 265 (glance) 66 | - Forerunner 265s (glance) 67 | - Forerunner 645 (glance) 68 | - Forerunner 645 Music (glance) 69 | - Forerunner 735XT 70 | - Forerunner 745 (glance) 71 | - Forerunner 935 72 | - Forerunner 945 (glance) 73 | - Forerunner 945 LTEglance) 74 | - Forerunner 955 75 | - Forerunner 965 76 | - Instinct 2 77 | - Instinct 2S 78 | - Instinct 2X 79 | - Instinct Crossover 80 | - MARQ Adventurer (glance) 81 | - MARQ Athlete (glance) 82 | - MARQ Aviator (glance) 83 | - MARQ Captain (glance) 84 | - MARQ Commander (glance) 85 | - MARQ Driver (glance) 86 | - MARQ Expedition (glance) 87 | - MARQ Golfer (glance) 88 | - MARQ 2 (glance) 89 | - MARQ 2 Aviator (glance) 90 | - Rey 91 | - Venu 2 (glance) 92 | - Venu 2 Plus (glance) 93 | - Venu 2S (glance) 94 | - Venu D 95 | - Venu Mercedes Benz 96 | - Venu Sq 2 97 | - Venu Sq 2 Music 98 | - vivoactive 4 99 | - vivoactive 4S 100 | 101 | ### Can download, but not start workout 102 | - Venu Sq 103 | - Venu Sq Music 104 | 105 | ### Not download capable 106 | - D2 Air 107 | - D2 Air x10 108 | - D2 Bravo 109 | - D2 Bravo Titanium 110 | - fenix 3 111 | - fenix 3 HR 112 | - Forerunner 230 113 | - Forerunner 235 114 | - Forerunner 630 115 | - Forerunner 920XT 116 | - Venu 117 | - vivoactive 118 | - vivoactive 3 119 | - vivoactive 3 Mercedes Benz 120 | - vivoactive 3 Music 121 | - vivoactive 3 Music LTE 122 | - vivoactive HR 123 | 124 | 125 | When run on non download capable devices it should still show the next workout 126 | and allow ajusting the workout preferences. 127 | 128 | ## To build 129 | - Install Visual Studio with Garmin ConnectIQ plugin and configure 130 | - Checkout this repository 131 | - Copy source/Config.mc.template to source/Config.mc 132 | - Obtain ClientId and ClientSecret from garmin@trainasone.com 133 | - Make the world a better place (hoo!) 134 | 135 | This is still a work in progress. It runs as a widget. 136 | 137 | ## Workout files overview 138 | 139 | The Garmin FIT format can be used to hold a workout description, 140 | essentially a list of steps, each with a duration and target pace 141 | or heart rate range. The Garmin Connect system can push workout 142 | files all watches which support advanced workouts. 143 | 144 | A subset of these watches can run Connect IQ apps, and a subset of 145 | these support PersistedContent, which allows Connect IQ apps to 146 | download FIT workout files and pass them to the watch to run. 147 | 148 | ## How does the app work 149 | 150 | On startup the app checks if there is an OAuth2 token stored for 151 | the current server host. If one is not found the user is redirected 152 | to the TrainAsONE server on their phone to login and grant access, 153 | which then returns a grant token back to the app. The app then 154 | connects back to the TrainAsONE server to convert this grant token 155 | to an access token, which is then stored. 156 | 157 | The app then uses the access token to request a plannedWorkoutSummary 158 | from the TrainAsONE server, which is a JSON object containing the 159 | user's preferences and some summary data about their next workout 160 | (These are merged into a single request to reduce the number of 161 | network calls required). 162 | 163 | If successful the workout summary is stored and if supported by 164 | the watch the matching workout FIT file is also downloaded. The 165 | summary is then shown to the user. 166 | 167 | If the request fails an error message is shown with an option to 168 | show any previously stored summary. 169 | 170 | When displaying a workout the user can press the menu or select 171 | buttons to select different options such as switching the workout 172 | stop target from pace to heart rate, switching temperature adjustment 173 | or logging in as a different TrainAsONE account. 174 | 175 | ## Source formatting preferences 176 | - 2 character spaces 177 | - Use spaces rather than tabs 178 | - Unix line endings 179 | 180 | ## Releasing 181 | - Update version in source/Version.mc 182 | - Copy manifest-downloadcapable.xml to manifest.xml, run Application Export Wizard, upload generated .iq as TrainAsONE 183 | - Copy manifest-allwatches.xml to manifest.xml, run Application Export Wizard, save generated .iq as TrainAsONE-lite 184 | -------------------------------------------------------------------------------- /manifest.xml: -------------------------------------------------------------------------------- 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 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | eng 143 | 144 | 145 | 146 | -------------------------------------------------------------------------------- /manifest-beta.xml: -------------------------------------------------------------------------------- 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 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | eng 143 | 144 | 145 | 146 | -------------------------------------------------------------------------------- /manifest-allwatches.xml: -------------------------------------------------------------------------------- 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 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | eng 143 | 144 | 145 | 146 | -------------------------------------------------------------------------------- /source/WorkoutFormatter.mc: -------------------------------------------------------------------------------- 1 | import Toybox.Application; 2 | import Toybox.Lang; 3 | import Toybox.Graphics; 4 | import Toybox.Math; 5 | import Toybox.Time.Gregorian; 6 | import Toybox.Time; 7 | import Toybox.WatchUi; 8 | 9 | (:glance) 10 | class WorkoutFormatter { 11 | private var mModel as TaoModel; 12 | 13 | function initialize() { 14 | mModel = Application.getApp().model; 15 | } 16 | 17 | function buildMessageFromWorkout() as String { 18 | if (mModel.accessToken == null) { 19 | return WatchUi.loadResource(Rez.Strings.selectToConnect); 20 | } 21 | var serverMessage = mModel.getMessage(); 22 | if (!mModel.hasWorkout()) { 23 | return serverMessage != null 24 | ? serverMessage 25 | : WatchUi.loadResource( 26 | mModel.isExternalSchedule() 27 | ? Rez.Strings.workoutNoWorkoutExternalSchedule 28 | : Rez.Strings.workoutNoWorkout 29 | ); 30 | } 31 | 32 | var displayPreferences = mModel.getDisplayPreferences(); 33 | var details = serverMessage != null ? serverMessage + "\n" : ""; 34 | 35 | var distance = mModel.lookupWorkoutSummary("distance") as Float?; 36 | if (distance != null && distance > 0.0) { 37 | details += formatDistance(distance, displayPreferences); 38 | } 39 | var duration = mModel.lookupWorkoutSummary("duration") as Number?; 40 | if (duration) { 41 | if (!details.equals("")) { 42 | details += ", "; 43 | } 44 | details += formatDuration(duration); 45 | } 46 | 47 | var message = Lang.format( 48 | WatchUi.loadResource(Rez.Strings.workoutNextWorkoutString), 49 | [ 50 | mModel.getName(), 51 | details, 52 | formatDate(parseDate(mModel.lookupWorkoutSummary("start"))), 53 | ] 54 | ); 55 | var extra = ""; 56 | var temperature = mModel.lookupWorkoutSummary("temperature") as Float?; 57 | if (temperature != null) { 58 | extra += formatTemperature(temperature, displayPreferences) + " "; 59 | } 60 | var undulation = mModel.lookupWorkoutSummary("undulation") as Float?; 61 | if (undulation != null) { 62 | extra += undulation.format("%0.1f") + "U "; 63 | } 64 | if (mModel.updated) { 65 | extra += "* "; 66 | } 67 | 68 | if (!extra.equals("")) { 69 | message += "\n(" + extra.substring(0, extra.length() - 1) + ")"; 70 | } 71 | return message; 72 | } 73 | 74 | function formatDistance( 75 | distance as Float?, 76 | displayPreferences as Dictionary 77 | ) as String { 78 | var units = ""; // Fake init to placate false positive "Variable units may not have been initialized in all code paths" 79 | var format = "%0.0f"; 80 | switch (displayPreferences["distanceUnit"] as String) { 81 | case "MILE": 82 | distance = (distance * 0.621371192) / 1000; 83 | format = "%0.1f"; 84 | units = WatchUi.loadResource(Rez.Strings.unitsMiles); 85 | break; 86 | case "METRE": 87 | units = WatchUi.loadResource(Rez.Strings.unitsMetres); 88 | break; 89 | case "CENTIMETRE": 90 | distance = distance * 100; 91 | units = WatchUi.loadResource(Rez.Strings.unitsCentimetres); 92 | break; 93 | case "MILLIMETRE": 94 | distance = distance * 1000; 95 | units = WatchUi.loadResource(Rez.Strings.unitsMillimetres); 96 | break; 97 | case "FOOT": 98 | distance = (distance / 1000) * 0.621371192 * 5280; 99 | units = WatchUi.loadResource(Rez.Strings.unitsFeet); 100 | break; 101 | case "PARSEC": 102 | distance = distance / (3.2407792896664 * Math.pow(10, 17)); 103 | units = WatchUi.loadResource(Rez.Strings.unitsParsecs); 104 | format = "%0.2e"; 105 | break; 106 | case "KILOMETRE": 107 | default: 108 | distance = distance / 1000; 109 | format = "%0.1f"; 110 | units = WatchUi.loadResource(Rez.Strings.unitsKilometres); 111 | break; 112 | } 113 | return Lang.format("$1$ $2$", [distance.format(format), units]); 114 | } 115 | 116 | function formatDuration(duration as Number) as String { 117 | var units = WatchUi.loadResource(Rez.Strings.unitsMinutes); 118 | return Lang.format("$1$ $2$", [duration / 60, units]); 119 | } 120 | 121 | function formatTemperature( 122 | temp as Float, 123 | displayPreferences as Dictionary 124 | ) as String { 125 | var units; 126 | if (displayPreferences["temperaturesInFahrenheit"]) { 127 | temp = (temp * 9) / 5 + 32; 128 | temp = temp.format("%d"); 129 | units = WatchUi.loadResource(Rez.Strings.unitsFahrenheit); 130 | } else { 131 | temp = temp.format("%0.1f"); 132 | units = WatchUi.loadResource(Rez.Strings.unitsCelsius); 133 | } 134 | return Lang.format("$1$$2$", [temp, units]); 135 | } 136 | 137 | function formatDate(moment as Moment) as String { 138 | var now = Time.now(); 139 | var info = Gregorian.info(moment, Time.FORMAT_MEDIUM); 140 | var oneDay = new Time.Duration(Gregorian.SECONDS_PER_DAY); 141 | var minusOneDay = new Time.Duration(-Gregorian.SECONDS_PER_DAY); 142 | 143 | var dayName; 144 | if (isSameDay(info, Gregorian.info(now, Time.FORMAT_MEDIUM))) { 145 | dayName = WatchUi.loadResource(Rez.Strings.dayToday); 146 | } else if ( 147 | isSameDay(info, Gregorian.info(now.add(oneDay), Time.FORMAT_MEDIUM)) 148 | ) { 149 | dayName = WatchUi.loadResource(Rez.Strings.dayTomorrow); 150 | } else if ( 151 | isSameDay(info, Gregorian.info(now.add(minusOneDay), Time.FORMAT_MEDIUM)) 152 | ) { 153 | dayName = WatchUi.loadResource(Rez.Strings.dayYesterday); 154 | } else { 155 | dayName = Lang.format("$1$ $2$ $3$", [ 156 | info.day_of_week, 157 | info.day, 158 | info.month, 159 | ]); 160 | } 161 | return Lang.format("$1$:$2$ $3$", [ 162 | info.hour.format("%02d"), 163 | info.min.format("%02d"), 164 | dayName, 165 | ]); 166 | } 167 | 168 | function parseDate(string as String?) as Moment? { 169 | // We want to handle ISO8601 UTC dates only: eg 2017-09-22T11:30:00Z 170 | if (string == null) { 171 | return null; 172 | } 173 | if (string.length() != 20) { 174 | return null; 175 | } 176 | return Gregorian.moment({ 177 | :year => string.substring(0, 4).toNumber(), 178 | :month => string.substring(5, 7).toNumber(), 179 | :day => string.substring(8, 10).toNumber(), 180 | :hour => string.substring(11, 13).toNumber(), 181 | :minute => string.substring(14, 16).toNumber(), 182 | :second => string.substring(17, 19).toNumber(), 183 | }); 184 | } 185 | 186 | function isSameDay(moment1 as Info, moment2 as Info) as Boolean { 187 | return ( 188 | moment1.day == moment2.day && 189 | moment1.month.equals(moment2.month) && 190 | moment1.year == moment2.year 191 | ); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /source/DownloadRequest.mc: -------------------------------------------------------------------------------- 1 | import Toybox.Application; 2 | import Toybox.Communications; 3 | import Toybox.Lang; 4 | import Toybox.PersistedContent; 5 | import Toybox.System; 6 | import Toybox.Timer; 7 | import Toybox.WatchUi; 8 | 9 | class DownloadRequest extends RequestDelegate { 10 | const downloadTimeout = 19; 11 | private var mModel as TaoModel; 12 | private var netUtil as NetUtil; 13 | 14 | private var _downloadViewRef as WeakReference; 15 | private var _downloadResponseCalled as Boolean = false; 16 | private var _downloadTimer as Timer.Timer = new Timer.Timer(); 17 | private var _downloadTimerCount as Number = 0; 18 | 19 | function initialize(downloadView as DownloadView) { 20 | RequestDelegate.initialize(); 21 | mModel = Application.getApp().model; 22 | netUtil = new NetUtil(); 23 | _downloadViewRef = downloadView.weak(); // Avoid a circular reference 24 | } 25 | 26 | // Note on "jsonErrors" 27 | // Currently (2.3.4) the Simulator does not appear to see any non 220 responseCodes unless the 28 | // server sets the media type of JSON, and at least the 735xt connected to a Garmin Mobile app 29 | // doesn't see them even in that case (it repeats the request 20 times and then returns -300) 30 | // So jsonErrors tells the server to wrap any response code errors in JSON and return them with 31 | // status 200. Suggestions as to how to better handle this appreciated 32 | // 33 | function start() as Void { 34 | downloadWorkoutSummary(); 35 | } 36 | 37 | function downloadWorkoutSummary() as Void { 38 | var url = mModel.serverUrl + "/api/mobile/plannedWorkoutSummary"; 39 | var options = { 40 | :method => Communications.HTTP_REQUEST_METHOD_POST, 41 | :headers => { 42 | "Authorization" => "Bearer " + mModel.accessToken, 43 | "Content-Type" => Communications.REQUEST_CONTENT_TYPE_JSON, 44 | }, 45 | :responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON, 46 | }; 47 | updateState("fetching summary"); 48 | try { 49 | Communications.makeWebRequest( 50 | url, 51 | setupParams(), 52 | options, 53 | method(:onDownloadWorkoutSummaryResponse) 54 | ); 55 | } catch (e) { 56 | MessageUtil.showErrorResource(Rez.Strings.errorUnexpectedUpdateError); 57 | } 58 | } 59 | 60 | function onDownloadWorkoutSummaryResponse( 61 | responseCode as Number, 62 | data as Dictionary or String or Iterator or Null 63 | ) as Void { 64 | updateState("updating summary"); 65 | responseCode = NetUtil.extractResponseCode(responseCode, data); 66 | if (responseCode != 200) { 67 | handleErrorResponseCode("summary", responseCode); 68 | } else if (data == null) { 69 | MessageUtil.showErrorResource(Rez.Strings.noWorkoutSummary); 70 | } else { 71 | mModel.updateWorkoutSummary(data); 72 | downloadWorkout(); 73 | } 74 | } 75 | 76 | function downloadWorkout() as Void { 77 | var downloadStatus = mModel.determineDownloadStatus(); 78 | if (downloadStatus != DownloadStatus.OK) { 79 | noWorkoutDownloaded(downloadStatus); 80 | return; 81 | } 82 | 83 | // Null-op on at least 735xt as watch shows Garmin "Updating" page automatically 84 | updateState("request download"); 85 | 86 | // var url = $mModel.serverUrl + "/api/mobile/plannedWorkoutDownload"; 87 | // var options = { 88 | // :method => Communications.HTTP_REQUEST_METHOD_POST, 89 | // :headers => { 90 | // "Authorization" => "Bearer " + mModel.accessToken, 91 | // "Content-Type" => Communications.REQUEST_CONTENT_TYPE_JSON 92 | // }, 93 | // :responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_FIT 94 | // }; 95 | 96 | // For now use old request endpoint as setting Communications.REQUEST_CONTENT_TYPE_JSON 97 | // on a FIT endpoint explodes on devices (runs fine in simulator) 98 | var url = mModel.serverUrl + "/api/mobile/plannedWorkout"; 99 | var options = { 100 | :method => Communications.HTTP_REQUEST_METHOD_GET, 101 | :headers => { 102 | "Authorization" => "Bearer " + mModel.accessToken, 103 | }, 104 | :responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_FIT, 105 | }; 106 | 107 | // _downloadTimerCount is always stopped before we finish 108 | // either onDownloadWorkoutResponse is called, or onDownloadTimeout hits its count limit 109 | _downloadTimer.start(method(:onDownloadTimeout), 1000, true); 110 | 111 | try { 112 | Communications.makeWebRequest( 113 | url, 114 | setupParams(), 115 | options, 116 | method(:onDownloadWorkoutResponse) 117 | ); 118 | } catch (e instanceof Lang.SymbolNotAllowedException) { 119 | MessageUtil.showErrorResource( 120 | Rez.Strings.errorUnexpectedDownloadNotAllowedError 121 | ); 122 | } catch (e) { 123 | MessageUtil.showErrorResource(Rez.Strings.errorUnexpectedDownloadError); 124 | } 125 | updateState("downloading"); 126 | } 127 | 128 | // On 245 & 945 firmware 3.90 the download never completes 129 | function onDownloadTimeout() as Void { 130 | ++_downloadTimerCount; 131 | Application.getApp().log("downloadTimer " + _downloadTimerCount); 132 | if (_downloadTimerCount > downloadTimeout && !_downloadResponseCalled) { 133 | _downloadTimer.stop(); 134 | updateState("download timeout"); 135 | mModel.setWorkoutMessageResource(Rez.Strings.downloadTimeout); 136 | noWorkoutDownloaded(DownloadStatus.DOWNLOAD_TIMEOUT); 137 | } 138 | } 139 | 140 | /// XXX Workaround Garmin Type Check mismatch, pending update from Garmin 141 | private function asPersistentContentIterator( 142 | value as Object? 143 | ) as PersistedContent.Iterator? { 144 | return value instanceof PersistedContent.Iterator 145 | ? value as PersistedContent.Iterator 146 | : null; 147 | } 148 | 149 | function onDownloadWorkoutResponse( 150 | responseCode as Number, 151 | data as Null or Dictionary or String 152 | ) as Void { 153 | _downloadResponseCalled = true; 154 | _downloadTimer.stop(); 155 | var downloads = asPersistentContentIterator(data); 156 | updateState("saving"); 157 | var download = downloads == null ? null : downloads.next(); 158 | // Application.getApp().log("handleDownloadWorkoutResponse: " + responseCode + " " + (download == null ? null : download.getName() + "/" + download.getId())); 159 | if (responseCode == 200) { 160 | if (download == null) { 161 | Application.getApp().log("FIT download: null"); 162 | mModel.setWorkoutMessageResource(Rez.Strings.noWorkoutSpace); 163 | noWorkoutDownloaded(DownloadStatus.RESPONSE_MISSING_WORKOUT_DATA); 164 | } else { 165 | mModel.updateDownload(download as Workout); 166 | /* A little simulator entertainment: 167 | * - The following popView seems to be required on the Forerunner 735XT 168 | * otherwise when the user redownloads the workout (for example when 169 | * switching settings) each DownloadView will stack, until the widget 170 | * runs out of stack and crashes 171 | * The 735XT is the only workout download capable watch which cannot 172 | * run monkeyVersion 3 or later, so conditionalise on 2.x or earlier 173 | * - In the simulator calling it will immediately exit the widget, 174 | * which matches the behaviour on all other devices, but is obviously 175 | * different to the actual hardware. So have a build time define so 176 | * we can exclude the device uniqueIdentifer returned by the Simulator 177 | */ 178 | var deviceSettings = System.getDeviceSettings(); 179 | var uid = deviceSettings.uniqueIdentifier; 180 | if (deviceSettings.monkeyVersion[0] < 3 && uid != null) { 181 | Application.getApp().log("preMonkeyV3 uniqueIdentifier(" + uid + ")"); 182 | if (!uid.equals($.ExcludeViewStackWorkaroundPreMonkeyV3)) { 183 | WatchUi.popView(WatchUi.SLIDE_IMMEDIATE); 184 | } 185 | } 186 | showWorkout(); 187 | } 188 | } else if (responseCode == 0) { 189 | Application.getApp().log("FIT download: response code 0"); 190 | mModel.setWorkoutMessageResource(Rez.Strings.noWorkoutSpace); 191 | noWorkoutDownloaded(DownloadStatus.RESPONSE_CODE_ZERO); 192 | } else if (responseCode == 403) { 193 | // XXX Never seen on watch hardware as of at least 2.3.4 - flattened to 0 194 | noWorkoutDownloaded( 195 | DownloadStatus.INSUFFICIENT_SUBSCRIPTION_CAPABILITIES 196 | ); 197 | } else { 198 | handleErrorResponseCode("download", responseCode); 199 | } 200 | } 201 | 202 | function noWorkoutDownloaded(reason as Number) as Void { 203 | Application.getApp().log("noWorkoutDownloaded: " + reason); 204 | mModel.setDownloadStatus(reason); 205 | showWorkout(); 206 | } 207 | 208 | function showWorkout() as Void { 209 | WatchUi.switchToView( 210 | new WorkoutView(), 211 | new WorkoutDelegate(), 212 | WatchUi.SLIDE_IMMEDIATE 213 | ); 214 | } 215 | 216 | function setupParams() as Dictionary? { 217 | var params = NetUtil.deviceParams(); 218 | params["jsonErrors"] = "true"; // wrap any response code errors in JSON 219 | var keys = mModel.localPref.keys(); 220 | for (var i = 0; i < keys.size(); ++i) { 221 | params[keys[i]] = mModel.localPref[keys[i]]; 222 | } 223 | Application.getApp().log("params: " + params); 224 | return params; 225 | } 226 | 227 | function updateState(stateText as String) as Void { 228 | if (_downloadViewRef.stillAlive()) { 229 | var downloadView = _downloadViewRef.get() as DownloadView?; 230 | if (downloadView != null) { 231 | downloadView.updateState(stateText); 232 | } 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /source/WorkoutDelegate.mc: -------------------------------------------------------------------------------- 1 | import Toybox.Application; 2 | import Toybox.Communications; 3 | import Toybox.Lang; 4 | import Toybox.System; 5 | import Toybox.WatchUi; 6 | 7 | class WorkoutDelegate extends WatchUi.BehaviorDelegate { 8 | private var mModel as TaoModel; 9 | 10 | function initialize() { 11 | BehaviorDelegate.initialize(); 12 | mModel = Application.getApp().model; 13 | } 14 | 15 | function onMenu() as Boolean { 16 | return showMenu(); 17 | } 18 | 19 | function onSelect() as Boolean { 20 | return showMenu(); 21 | } 22 | 23 | function showMenu() as Boolean { 24 | var menu = new Menu(); 25 | if (mModel.hasWorkout()) { 26 | menu.setTitle(mModel.getName()); 27 | } 28 | switch (mModel.downloadStatus) { 29 | case DownloadStatus.OK: 30 | menu.addItem( 31 | WatchUi.loadResource(Rez.Strings.menuStartWorkout), 32 | :startWorkout 33 | ); 34 | menu.addItem( 35 | WatchUi.loadResource(Rez.Strings.stepTarget) + 36 | ": " + 37 | mModel.mergedStepTarget(), 38 | :adjustStepTarget 39 | ); 40 | menu.addItem( 41 | WatchUi.loadResource(Rez.Strings.stepNames) + 42 | ": " + 43 | mModel.mergedStepName(), 44 | :adjustStepName 45 | ); 46 | menu.addItem( 47 | WatchUi.loadResource(Rez.Strings.menuIncludeRunBackStep) + 48 | ": " + 49 | yesNo(mModel.mergedIncludeRunBackStep()), 50 | :adjustIncludeRunBackStep 51 | ); 52 | break; 53 | case DownloadStatus.EXTERNAL_SCHEDULE: 54 | case DownloadStatus.NO_WORKOUT_AVAILABLE: 55 | menu.addItem( 56 | WatchUi.loadResource(Rez.Strings.menuOpenCommitments), 57 | :openCommitments 58 | ); 59 | break; 60 | case DownloadStatus.DEVICE_DOES_NOT_SUPPORT_DOWNLOAD: 61 | menu.addItem( 62 | WatchUi.loadResource(Rez.Strings.menuDownloadNotSupported), 63 | :noWorkoutDownloadNotSupported 64 | ); 65 | break; 66 | case DownloadStatus.INSUFFICIENT_SUBSCRIPTION_CAPABILITIES: 67 | menu.addItem( 68 | mModel.problemResource(Rez.Strings.menuStartWorkout), 69 | :noWorkoutInsufficientSubscriptionCapabilities 70 | ); 71 | break; 72 | case DownloadStatus.WORKOUT_NOT_DOWNLOAD_CAPABLE: 73 | menu.addItem( 74 | mModel.problemResource(Rez.Strings.menuStartWorkout), 75 | :noWorkoutNotDownloadCapable 76 | ); 77 | break; 78 | case DownloadStatus.RESPONSE_CODE_ZERO: 79 | menu.addItem( 80 | mModel.problemResource(Rez.Strings.menuStartWorkout), 81 | :cannotLoadWorkoutData 82 | ); 83 | break; 84 | case DownloadStatus.RESPONSE_MISSING_WORKOUT_DATA: 85 | menu.addItem( 86 | mModel.problemResource(Rez.Strings.menuStartWorkout), 87 | :cannotLoadWorkoutData 88 | ); 89 | break; 90 | case DownloadStatus.DOWNLOAD_TIMEOUT: 91 | menu.addItem( 92 | mModel.problemResource(Rez.Strings.menuStartWorkout), 93 | :noWorkoutDownloadTimeout 94 | ); 95 | break; 96 | } 97 | 98 | if (mModel.isAdjustPermitted()) { 99 | menu.addItem( 100 | WatchUi.loadResource(Rez.Strings.adjustTemperature) + 101 | ": " + 102 | yesNo(mModel.mergedAdjustForTemperature()), 103 | :adjustTemperature 104 | ); 105 | menu.addItem( 106 | WatchUi.loadResource(Rez.Strings.adjustUndulation) + 107 | ": " + 108 | yesNo(mModel.mergedAdjustForUndulation()), 109 | :adjustUndulation 110 | ); 111 | } 112 | 113 | menu.addItem( 114 | WatchUi.loadResource(Rez.Strings.menuRefetchWorkout), 115 | :refetchWorkout 116 | ); 117 | 118 | mModel.addStandardMenuOptions(menu); 119 | WatchUi.pushView(menu, new WorkoutMenuDelegate(null), WatchUi.SLIDE_UP); 120 | return true; 121 | } 122 | 123 | static function yesNo(val as Boolean) as String { 124 | return WatchUi.loadResource(val ? Rez.Strings.yes : Rez.Strings.no); 125 | } 126 | } 127 | 128 | class WorkoutMenuDelegate extends WatchUi.MenuInputDelegate { 129 | private var mModel as TaoModel; 130 | private var _activeTransaction as DeferredIntent?; 131 | private var _url as String?; 132 | 133 | function initialize(url as String?) { 134 | MenuInputDelegate.initialize(); 135 | mModel = Application.getApp().model; 136 | _url = url; 137 | } 138 | 139 | public function handleDeferredIntent(intent as Intent) as Void { 140 | _activeTransaction = null; 141 | System.exitTo(intent); 142 | } 143 | 144 | function onMenuItem(item) { 145 | switch (item) { 146 | case :moreInfo: 147 | Communications.openWebPage(_url, NetUtil.deviceParams(), null); 148 | break; 149 | case :about: 150 | MessageUtil.showAbout(); 151 | break; 152 | case :openCommitments: 153 | Communications.openWebPage( 154 | mModel.serverUrl + "/commitments", 155 | null, 156 | null 157 | ); 158 | break; 159 | case :openWebsite: 160 | Communications.openWebPage(mModel.serverUrl, null, null); 161 | break; 162 | case :startWorkout: 163 | var intent = mModel.downloadIntent; 164 | // Deferred intent handling workaround from Garmin for 645 firmware (SDK 3.0.3) issue. May no longer be needed? 165 | if (mModel.mergedDeferredIntent()) { 166 | Application.getApp().log("intent: deferred"); 167 | _activeTransaction = new DeferredIntent(self, intent); 168 | return; 169 | } else { 170 | Application.getApp().log("intent: instant"); 171 | try { 172 | System.exitTo(intent); 173 | } catch (e) { 174 | // The Venu Sq can download workouts, but throws an exception when the intent is called 175 | // Interestingly it can start a workout from the Training Calendar just fine 176 | MessageUtil.showErrorResourceWithUrl( 177 | Rez.Strings.errorCannotStartWorkout, 178 | Urls.NOT_DOWNLOAD_CAPABLE 179 | ); 180 | } 181 | } 182 | break; 183 | default: 184 | WatchUi.popView(WatchUi.SLIDE_IMMEDIATE); 185 | break; 186 | } 187 | 188 | var downloadReason = null; // XXX i18n 189 | switch (item) { 190 | case :adjustIncludeRunBackStep: 191 | downloadReason = 192 | "Run back step set to\n" + 193 | WorkoutDelegate.yesNo(mModel.adjustIncludeRunBackStep()); 194 | break; 195 | case :adjustStepName: 196 | downloadReason = "Step name set to\n" + mModel.adjustStepName(); 197 | break; 198 | case :adjustStepTarget: 199 | downloadReason = "Step target set to\n" + mModel.adjustStepTarget(); 200 | break; 201 | case :adjustTemperature: 202 | downloadReason = 203 | "Adjust temperature set to\n" + 204 | WorkoutDelegate.yesNo(mModel.adjustAdjustForTemperature()); 205 | break; 206 | case :adjustUndulation: 207 | downloadReason = 208 | "Adjust undulation set to\n" + 209 | WorkoutDelegate.yesNo(mModel.adjustAdjustForUndulation()); 210 | break; 211 | case :refetchWorkout: 212 | downloadReason = "Refetching workout"; 213 | break; 214 | case :retry: 215 | downloadReason = "Retrying"; 216 | break; 217 | case :showSaved: 218 | WatchUi.switchToView( 219 | new WorkoutView(), 220 | new WorkoutDelegate(), 221 | WatchUi.SLIDE_IMMEDIATE 222 | ); 223 | break; 224 | case :switchServer: 225 | mModel.switchServer(); 226 | downloadReason = "Switching server to\n" + mModel.serverUrl; 227 | break; 228 | case :switchUser: 229 | WatchUi.switchToView( 230 | new GrantView(false, true), 231 | new GrantDelegate(), 232 | WatchUi.SLIDE_IMMEDIATE 233 | ); 234 | break; 235 | // error cases below 236 | case :noWorkoutDownloadNotSupported: 237 | MessageUtil.showErrorResourceWithUrl( 238 | Rez.Strings.errorDownloadNotSupported, 239 | Urls.NOT_DOWNLOAD_NOT_SUPPORTED 240 | ); 241 | break; 242 | case :noWorkoutNotDownloadCapable: 243 | MessageUtil.showErrorResourceWithUrl( 244 | Rez.Strings.errorNotDownloadCapable, 245 | Urls.NOT_DOWNLOAD_CAPABLE 246 | ); 247 | break; 248 | case :noWorkoutInsufficientSubscriptionCapabilities: 249 | MessageUtil.showErrorResourceWithUrl( 250 | Rez.Strings.errorInsufficientSubscriptionCapabilities, 251 | Urls.INSUFFICIENT_SUBSCRIPTION_CAPABILITIES 252 | ); 253 | break; 254 | case :noWorkoutDownloadTimeout: 255 | MessageUtil.showErrorResourceWithUrl( 256 | Rez.Strings.errorDownloadTimeout, 257 | Urls.DOWNLOAD_TIMEOUT 258 | ); 259 | break; 260 | case :cannotLoadWorkoutData: 261 | MessageUtil.showErrorResourceWithUrl( 262 | Rez.Strings.errorCannotLoadWorkoutData, 263 | Urls.CANNOT_LOAD_WORKOUT_DATA 264 | ); 265 | break; 266 | } 267 | 268 | if (downloadReason != null) { 269 | WatchUi.switchToView( 270 | new DownloadView(downloadReason), 271 | new DownloadDelegate(), 272 | WatchUi.SLIDE_IMMEDIATE 273 | ); 274 | } 275 | } 276 | 277 | /* 278 | // We could include this in an error message when workout storage is "full" 279 | function countDownloadedWorkouts() { 280 | int count = 0; 281 | if (Toybox has :PersistedContent) { 282 | var iterator = PersistedContent.getWorkouts(); 283 | var workout = iterator.next(); 284 | while (workout != null) { 285 | ++count; 286 | workout = iterator.next(); 287 | } 288 | } 289 | return count; 290 | } 291 | */ 292 | } 293 | -------------------------------------------------------------------------------- /source/TaoModel.mc: -------------------------------------------------------------------------------- 1 | import Toybox.Application; 2 | import Toybox.PersistedContent; 3 | import Toybox.System; 4 | import Toybox.WatchUi; 5 | import Toybox.Lang; 6 | 7 | (:glance) 8 | class TaoModel { 9 | const STORE_ACCESS_TOKEN = "accessToken"; 10 | const STORE_SUMMARY = "summary"; 11 | const STORE_MESSAGE = "message"; 12 | const STORE_DOWNLOAD_NAME = "workoutName"; 13 | const STORE_DOWNLOAD_STATUS = "downloadResult"; 14 | const STORE_SERVER_URL = "serverUrl"; 15 | 16 | const PREF_WORKOUT_STEP_NAME = "workoutStepName"; 17 | const PREF_WORKOUT_STEP_TARGET = "workoutStepTarget"; 18 | const PREF_ADJUST_FOR_TEMPERATURE = "adjustForTemperature"; 19 | const PREF_ADJUST_FOR_UNDULATION = "adjustForUndulation"; 20 | const PREF_INCLUDE_RUN_BACK_STEP = "includeRunBackStep"; 21 | const PREF_DEFERRED_INTENT = "deferredIntent"; 22 | 23 | const SUMMARY_NAME = "name"; 24 | const SUMMARY_DISPLAY_PREFERENCES = "displayPreferences"; 25 | const SUMMARY_MESSAGE = "message"; 26 | const SUMMARY_DOWNLOAD_CAPABLE = "downloadCapable"; 27 | const SUMMARY_DOWNLOAD_PERMITTED = "downloadPermitted"; 28 | const SUMMARY_EXTERNAL_SCHEDULE = "externalSchedule"; 29 | const SUMMARY_SUPPORT = "support"; 30 | 31 | const STEP_NAME_OPTIONS = 32 | ["STEP_NAME", "BLANK", "PACE_RANGE"] as Array; 33 | const STEP_TARGET_OPTIONS = 34 | ["SPEED", "HEART_RATE_RECOVERY", "HEART_RATE_SLOW", "HEART_RATE"] as 35 | Array; 36 | 37 | typedef Prefs as Dictionary< 38 | String, 39 | Boolean or 40 | Number or 41 | Double or 42 | String or 43 | Dictionary 44 | >; 45 | 46 | var accessToken as String?; // Access token returned by TrainAsONE Oauth2, used in later API calls 47 | var downloadStatus as Number = DownloadStatus.NOT_YET_ATTEMPTED; // Download result status 48 | var updated as Boolean = false; // Has the workout changed since our last stored version 49 | var downloadIntent as Intent?; // Stored intent, used to start workout 50 | var downloadName as String?; // Name for workout stored under PersistedContent 51 | var workoutSummary as Prefs?; // All details of workout and related data from server 52 | var workoutMessage as String?; // Alternate message to show (not yet used) 53 | var localPref as Prefs = ({}) as Prefs; // Locally overridden preferences 54 | var serverUrl as String; // Current server URL 55 | 56 | function determineDownloadIntentFromPersistedContent() as Intent? { 57 | var foundWorkout = null; 58 | if (downloadName != null && Toybox has :PersistedContent) { 59 | var perAppWorkouts = PersistedContent has :getAppWorkouts; 60 | if (!perAppWorkouts) { 61 | Application.getApp().log( 62 | "Device does not support removing own workouts" 63 | ); 64 | } 65 | var iterator = perAppWorkouts 66 | ? PersistedContent.getAppWorkouts() 67 | : PersistedContent.getWorkouts(); 68 | var workout = iterator.next(); 69 | while (workout != null) { 70 | var hasName = workout has :getName; 71 | if ( 72 | foundWorkout == null && 73 | hasName && 74 | workout.getName().equals(downloadName) 75 | ) { 76 | // Find the first match by name 77 | foundWorkout = workout.toIntent(); 78 | } else if (perAppWorkouts) { 79 | if (workout has :remove) { 80 | Application.getApp().log( 81 | "remove previous workout: " + workout.getName() 82 | ); 83 | workout.remove(); 84 | } else { 85 | Application.getApp().log( 86 | "ignore strange non-removable workout: " + 87 | (hasName ? workout.getName() : "UNKNOWN") 88 | ); 89 | } 90 | } 91 | workout = iterator.next(); 92 | } 93 | } 94 | return foundWorkout; 95 | } 96 | 97 | function initialize() { 98 | // Will reset to first entry is null, or not in current list 99 | serverUrl = findInList( 100 | loadStringProperty(STORE_SERVER_URL), 101 | $.ServerUrls, 102 | 0 103 | ); 104 | accessToken = loadStringProperty(STORE_ACCESS_TOKEN + "-" + serverUrl); 105 | if (accessToken == null) { 106 | // compat: Fallback to property used by 0.0.17 or earlier 107 | accessToken = loadStringProperty(STORE_ACCESS_TOKEN); 108 | } 109 | workoutSummary = loadProperty(STORE_SUMMARY); 110 | workoutMessage = loadStringProperty(STORE_MESSAGE); 111 | downloadName = loadStringProperty(STORE_DOWNLOAD_NAME); 112 | downloadStatus = loadProperty(STORE_DOWNLOAD_STATUS) as Number?; 113 | if (downloadStatus == null) { 114 | downloadStatus = DownloadStatus.NOT_YET_ATTEMPTED; 115 | } 116 | downloadIntent = determineDownloadIntentFromPersistedContent(); 117 | // Application.getApp().log("start: " + serverUrl + " " + accessToken); 118 | } 119 | 120 | function loadStringProperty(propertyName as String) as String? { 121 | return loadProperty(propertyName) as String?; 122 | } 123 | 124 | function loadProperty(propertyName as String) as Prefs or Number or Null { 125 | return ( 126 | Application.getApp().getProperty(propertyName) as Prefs or Number or Null 127 | ); 128 | } 129 | 130 | function saveProperty( 131 | propertyName as String, 132 | propertyValue as Prefs or String or Number or Null 133 | ) as Void { 134 | Application.getApp().setProperty(propertyName, propertyValue); 135 | } 136 | 137 | function problemResource(rez as ResourceId) as String { 138 | // Cannot embed non ascii in literal strings, hence badLeft & badRight 139 | return ( 140 | "" + 141 | WatchUi.loadResource(Rez.Strings.badLeft) + 142 | WatchUi.loadResource(rez) + 143 | WatchUi.loadResource(Rez.Strings.badRight) 144 | ); 145 | } 146 | 147 | function setWorkoutMessageResource(rez as ResourceId) as Void { 148 | workoutMessage = problemResource(rez); 149 | } 150 | 151 | function setDownloadStatus(updatedDownloadStatus as Number) as Void { 152 | downloadStatus = updatedDownloadStatus; 153 | saveProperty(STORE_DOWNLOAD_STATUS, downloadStatus); 154 | } 155 | 156 | function adjustStepTarget() as String { 157 | var stepTarget = findInList(mergedStepTarget(), STEP_TARGET_OPTIONS, 1); 158 | localPref[PREF_WORKOUT_STEP_TARGET] = stepTarget; 159 | return stepTarget; 160 | } 161 | 162 | function adjustStepName() as String { 163 | var stepName = findInList(mergedStepName(), STEP_NAME_OPTIONS, 1); 164 | localPref[PREF_WORKOUT_STEP_NAME] = stepName; 165 | return stepName; 166 | } 167 | 168 | function adjustAdjustForTemperature() as Boolean { 169 | return adjustBooleanPreference(PREF_ADJUST_FOR_TEMPERATURE); 170 | } 171 | 172 | function adjustAdjustForUndulation() as Boolean { 173 | return adjustBooleanPreference(PREF_ADJUST_FOR_UNDULATION); 174 | } 175 | 176 | function adjustIncludeRunBackStep() as Boolean { 177 | return adjustBooleanPreference(PREF_INCLUDE_RUN_BACK_STEP); 178 | } 179 | 180 | function adjustBooleanPreference(prefName as String) as Boolean { 181 | var newVal = !(mergedPreference(prefName) as Boolean); 182 | localPref[prefName] = newVal; 183 | return newVal; 184 | } 185 | 186 | function updateDownload(download as Workout) as Void { 187 | Application.getApp().log("updateDownload: " + download.getName()); 188 | setDownloadStatus(DownloadStatus.OK); 189 | downloadIntent = download.toIntent(); 190 | downloadName = download.getName(); 191 | determineDownloadIntentFromPersistedContent(); // Will clean out other workouts 192 | saveProperty(STORE_DOWNLOAD_NAME, downloadName); 193 | } 194 | 195 | function updateWorkoutSummary(updatedWorkoutSummary as Prefs) as Void { 196 | var oldName = getName() == null ? "" : getName(); 197 | var newName = 198 | updatedWorkoutSummary[SUMMARY_NAME] == null 199 | ? "" 200 | : updatedWorkoutSummary[SUMMARY_NAME]; 201 | workoutSummary = updatedWorkoutSummary; 202 | updated = !newName.equals(oldName); // XXX base on other changes too 203 | // Application.getApp().log("workoutSummary: " + workoutSummary); 204 | localPref = ({}) as Prefs; 205 | workoutMessage = null; 206 | saveProperty(STORE_SUMMARY, workoutSummary); 207 | } 208 | 209 | function setAccessToken(updatedAccessToken as String?) as Void { 210 | accessToken = updatedAccessToken; 211 | saveProperty(STORE_ACCESS_TOKEN + "-" + serverUrl, accessToken); 212 | } 213 | 214 | function getDisplayPreferences() as Prefs? { 215 | return lookupWorkoutSummary(SUMMARY_DISPLAY_PREFERENCES); 216 | } 217 | 218 | function getMessage() as String? { 219 | return workoutMessage == null 220 | ? lookupWorkoutSummary(SUMMARY_MESSAGE) as String 221 | : workoutMessage; 222 | } 223 | 224 | function getName() as String? { 225 | return lookupWorkoutSummary(SUMMARY_NAME) as String; 226 | } 227 | 228 | function mergedStepTarget() as String { 229 | return mergedPreference(PREF_WORKOUT_STEP_TARGET) as String; 230 | } 231 | 232 | function mergedStepName() as String { 233 | return mergedPreference(PREF_WORKOUT_STEP_NAME) as String; 234 | } 235 | 236 | function mergedAdjustForTemperature() as Boolean { 237 | return mergedPreference(PREF_ADJUST_FOR_TEMPERATURE) as Boolean; 238 | } 239 | 240 | function mergedAdjustForUndulation() as Boolean { 241 | return mergedPreference(PREF_ADJUST_FOR_UNDULATION) as Boolean; 242 | } 243 | 244 | function mergedIncludeRunBackStep() as Boolean { 245 | return mergedPreference(PREF_INCLUDE_RUN_BACK_STEP) as Boolean; 246 | } 247 | 248 | function mergedDeferredIntent() as Boolean { 249 | return mergedPreference(PREF_DEFERRED_INTENT) as Boolean; 250 | } 251 | 252 | function mergedPreference(prefName as String) { 253 | return localPref[prefName] == null 254 | ? lookupDisplayPreference(prefName) 255 | : prefName; 256 | } 257 | 258 | // For now key off the downloadPermitted setting 259 | function isAdjustPermitted() as Boolean { 260 | return isDownloadPermitted(); 261 | } 262 | 263 | function isDownloadCapable() as Boolean { 264 | return lookupWorkoutSummaryBoolean(SUMMARY_DOWNLOAD_CAPABLE); 265 | } 266 | 267 | function isDownloadPermitted() as Boolean { 268 | return lookupWorkoutSummaryBoolean(SUMMARY_DOWNLOAD_PERMITTED); 269 | } 270 | 271 | function isExternalSchedule() as Boolean { 272 | return lookupWorkoutSummaryBoolean(SUMMARY_EXTERNAL_SCHEDULE); 273 | } 274 | 275 | function isSupport() as Boolean { 276 | return lookupWorkoutSummaryBoolean(SUMMARY_SUPPORT); 277 | } 278 | 279 | function hasWorkout() as Boolean { 280 | return getName() != null; 281 | } 282 | 283 | function determineDownloadStatus() as Number { 284 | if (isExternalSchedule()) { 285 | return DownloadStatus.EXTERNAL_SCHEDULE; 286 | } 287 | if (!hasWorkout()) { 288 | return DownloadStatus.NO_WORKOUT_AVAILABLE; 289 | } 290 | if (!(Toybox has :PersistedContent)) { 291 | return DownloadStatus.DEVICE_DOES_NOT_SUPPORT_DOWNLOAD; 292 | } 293 | if (!isDownloadPermitted()) { 294 | return DownloadStatus.INSUFFICIENT_SUBSCRIPTION_CAPABILITIES; 295 | } 296 | if (!isDownloadCapable()) { 297 | return DownloadStatus.WORKOUT_NOT_DOWNLOAD_CAPABLE; 298 | } 299 | return DownloadStatus.OK; 300 | } 301 | 302 | // Helpers to lookup values safely in the presence of null workoutSummary/displayPreferences 303 | function lookupDisplayPreference( 304 | key as String 305 | ) as String or Number or Boolean { 306 | var displayPreferences = getDisplayPreferences(); 307 | return displayPreferences == null ? null : displayPreferences[key]; 308 | } 309 | 310 | function lookupWorkoutSummary( 311 | key as String 312 | ) as String or Number or Double or Prefs or Null { 313 | return workoutSummary == null ? null : workoutSummary[key]; 314 | } 315 | 316 | function lookupWorkoutSummaryBoolean(key as String) as Boolean { 317 | return workoutSummary == null ? false : workoutSummary[key]; 318 | } 319 | 320 | // XXX Should be moved out to controller class 321 | function addStandardMenuOptions(menu as Menu) as Void { 322 | menu.addItem( 323 | WatchUi.loadResource(Rez.Strings.menuOpenWebsite), 324 | :openWebsite 325 | ); 326 | menu.addItem(WatchUi.loadResource(Rez.Strings.menuSwitchUser), :switchUser); 327 | menu.addItem(WatchUi.loadResource(Rez.Strings.menuAbout), :about); 328 | if (isSupport()) { 329 | menu.addItem( 330 | WatchUi.loadResource(Rez.Strings.server) + ": " + serverUrl, 331 | :switchServer 332 | ); 333 | } 334 | } 335 | 336 | function updateServerUrl(offset as Number) as Void { 337 | serverUrl = findInList(serverUrl, $.ServerUrls, offset); 338 | } 339 | 340 | // Lookup current serverUrl in $.ServerUrls, and if found apply offset, wrapped at start/end 341 | function findInList( 342 | value as String?, 343 | list as Array, 344 | offset as Number 345 | ) as String { 346 | var i = 0; 347 | if (value != null) { 348 | for (; i < list.size(); ++i) { 349 | if (value.equals(list[i])) { 350 | break; 351 | } 352 | } 353 | } 354 | if (i >= list.size()) { 355 | // serverUrl not found in list 356 | i = 0; 357 | } 358 | i = i + offset; 359 | if (i >= list.size()) { 360 | // new index past end of list 361 | i = 0; 362 | } else if (i < 0) { 363 | // new index below start of list 364 | i = list.size() - 1; 365 | } 366 | 367 | return list[i]; 368 | } 369 | 370 | function switchServer() as Void { 371 | updateServerUrl(1); 372 | saveProperty(STORE_SERVER_URL, serverUrl); 373 | accessToken = loadStringProperty(STORE_ACCESS_TOKEN + "-" + serverUrl); 374 | } 375 | } 376 | --------------------------------------------------------------------------------