├── .gitignore ├── LICENSE ├── README.md ├── _examples ├── demo_database_install.php ├── demo_post.php └── demo_protocol.json ├── angular.json ├── config.xml ├── ionic.config.json ├── package-lock.json ├── package.json ├── resources ├── README.md ├── android │ ├── icon.png │ ├── icon.png.md5 │ ├── icon │ │ ├── drawable-hdpi-icon.png │ │ ├── drawable-ldpi-icon.png │ │ ├── drawable-mdpi-icon.png │ │ ├── drawable-xhdpi-icon.png │ │ ├── drawable-xxhdpi-icon.png │ │ └── drawable-xxxhdpi-icon.png │ ├── notification-icon │ │ ├── icon-hdpi.png │ │ ├── icon-mdpi.png │ │ ├── icon-xhdpi.png │ │ ├── icon-xxhdpi.png │ │ └── icon-xxxhdpi.png │ ├── small-icon.png │ ├── splash.png │ ├── splash.png.md5 │ └── splash │ │ ├── drawable-land-hdpi-screen.png │ │ ├── drawable-land-ldpi-screen.png │ │ ├── drawable-land-mdpi-screen.png │ │ ├── drawable-land-xhdpi-screen.png │ │ ├── drawable-land-xxhdpi-screen.png │ │ ├── drawable-land-xxxhdpi-screen.png │ │ ├── drawable-port-hdpi-screen.png │ │ ├── drawable-port-ldpi-screen.png │ │ ├── drawable-port-mdpi-screen.png │ │ ├── drawable-port-xhdpi-screen.png │ │ ├── drawable-port-xxhdpi-screen.png │ │ └── drawable-port-xxxhdpi-screen.png ├── icon.png ├── icon.png.md5 ├── ios │ ├── icon.png │ ├── icon.png.md5 │ ├── icon │ │ ├── icon-1024.png │ │ ├── icon-40.png │ │ ├── icon-40@2x.png │ │ ├── icon-40@3x.png │ │ ├── icon-50.png │ │ ├── icon-50@2x.png │ │ ├── icon-60.png │ │ ├── icon-60@2x.png │ │ ├── icon-60@3x.png │ │ ├── icon-72.png │ │ ├── icon-72@2x.png │ │ ├── icon-76.png │ │ ├── icon-76@2x.png │ │ ├── icon-83.5@2x.png │ │ ├── icon-small.png │ │ ├── icon-small@2x.png │ │ ├── icon-small@3x.png │ │ ├── icon.png │ │ └── icon@2x.png │ ├── splash.png │ ├── splash.png.md5 │ └── splash │ │ ├── Default-568h@2x~iphone.png │ │ ├── Default-667h.png │ │ ├── Default-736h.png │ │ ├── Default-Landscape-736h.png │ │ ├── Default-Landscape@2x~ipad.png │ │ ├── Default-Landscape@~ipadpro.png │ │ ├── Default-Landscape~ipad.png │ │ ├── Default-Portrait@2x~ipad.png │ │ ├── Default-Portrait@~ipadpro.png │ │ ├── Default-Portrait~ipad.png │ │ ├── Default@2x~iphone.png │ │ ├── Default@2x~universal~anyany.png │ │ └── Default~iphone.png ├── small-icon.png ├── splash.png └── splash.png.md5 ├── src ├── app │ ├── app-routing.module.ts │ ├── app.component.html │ ├── app.component.spec.ts │ ├── app.component.ts │ ├── app.module.ts │ ├── services │ │ ├── loading-service.service.spec.ts │ │ ├── loading-service.service.ts │ │ ├── notifications.service.spec.ts │ │ ├── notifications.service.ts │ │ ├── study-tasks.service.spec.ts │ │ ├── study-tasks.service.ts │ │ ├── survey-cache.service.spec.ts │ │ ├── survey-cache.service.ts │ │ ├── survey-data.service.spec.ts │ │ ├── survey-data.service.ts │ │ ├── uuid.service.spec.ts │ │ └── uuid.service.ts │ ├── survey │ │ ├── survey.module.ts │ │ ├── survey.page.html │ │ ├── survey.page.scss │ │ ├── survey.page.spec.ts │ │ └── survey.page.ts │ ├── tab1 │ │ ├── tab1.module.ts │ │ ├── tab1.page.html │ │ ├── tab1.page.scss │ │ ├── tab1.page.spec.ts │ │ └── tab1.page.ts │ ├── tab2 │ │ ├── tab2.module.ts │ │ ├── tab2.page.html │ │ ├── tab2.page.scss │ │ ├── tab2.page.spec.ts │ │ └── tab2.page.ts │ ├── tab3 │ │ ├── tab3.module.ts │ │ ├── tab3.page.html │ │ ├── tab3.page.scss │ │ ├── tab3.page.spec.ts │ │ └── tab3.page.ts │ ├── tabs │ │ ├── tabs.module.ts │ │ ├── tabs.page.html │ │ ├── tabs.page.scss │ │ ├── tabs.page.spec.ts │ │ ├── tabs.page.ts │ │ └── tabs.router.module.ts │ └── translate-config.service.ts ├── assets │ ├── i18n │ │ └── en.json │ ├── icon │ │ ├── favicon.png │ │ └── notification_icon.png │ ├── imgs │ │ ├── dark_circle.png │ │ ├── home-icon.png │ │ ├── icon.png │ │ ├── light_circle.png │ │ ├── loading.gif │ │ ├── slide2.png │ │ ├── slider1.png │ │ ├── small-icon.png │ │ └── spinner.gif │ └── shapes.svg ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── global.scss ├── index.html ├── karma.conf.js ├── main.ts ├── polyfills.ts ├── test.ts ├── theme │ └── variables.scss ├── tsconfig.app.json └── tsconfig.spec.json ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Specifies intentionally untracked files to ignore when using Git 2 | # http://git-scm.com/docs/gitignore 3 | 4 | *~ 5 | *.sw[mnpcod] 6 | *.log 7 | *.tmp 8 | *.tmp.* 9 | *.keystore 10 | log.txt 11 | *.sublime-project 12 | *.sublime-workspace 13 | .vscode/ 14 | npm-debug.log* 15 | 16 | .idea/ 17 | .ionic/ 18 | .sourcemaps/ 19 | .sass-cache/ 20 | .tmp/ 21 | .versions/ 22 | coverage/ 23 | e2e/ 24 | www/ 25 | node_modules/ 26 | tmp/ 27 | temp/ 28 | platforms/ 29 | plugins/ 30 | plugins/android.json 31 | plugins/ios.json 32 | $RECYCLE.BIN/ 33 | 34 | .DS_Store 35 | Thumbs.db 36 | UserInterfaceState.xcuserstate 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Adrian Shatte & Samantha Teague 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![schema](https://getschema.app/img/schema_banner.jpg)](https://getschema.app/) 2 | 3 | # schema 4 | 5 | [![GooglePlay](https://ashatte.io/img/google-play-badge-300x89.png)](https://play.google.com/store/apps/details?id=app.getschema) [![AppStore](https://ashatte.io/img/download-on-the-app-store.png)](https://apps.apple.com/au/app/schema/id1463316309) 6 | 7 | schema is a cross-platform mobile application for deploying mHealth monitoring and intervention studies. 8 | 9 | It supports: 10 | 11 | - A diverse range of elements, including slider, text input, date/time, audio, video, image, and more, with support for branching logic. 12 | - Flexible module scheduling, to deliver surveys and/or interventions to participants at random or fixed intervals. 13 | - Participant randomisation into distinct conditions with different modules and scheduling. 14 | - Study registration via scanning a QR code or directly entering protocol URL. 15 | - Dynamic feedback charts to track participant progress on specific variables. 16 | - Distributed architecture, such that study protocols and data can be stored on your own server. 17 | 18 | # Citation 19 | If you use schema in your own research, please cite the following: 20 | > Shatte, A. B. R., & Teague, S. J. (2020). schema: An open-source, distributed mobile platform for deploying mHealth research tools and interventions. BMC Medical Research Methodology, 20(1), 1-12. Retrieved from [https://bmcmedresmethodol.biomedcentral.com/articles/10.1186/s12874-020-00973-5](https://bmcmedresmethodol.biomedcentral.com/articles/10.1186/s12874-020-00973-5) 21 | 22 | > Shatte, A. B. R., & Teague, S. J. (2019, June 12). schema (Version 1.0). Zenodo. http://doi.org/10.5281/zenodo.3243918 23 | 24 | [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.3243918.svg)](https://doi.org/10.5281/zenodo.3243918) 25 | 26 | # Usage 27 | 28 | ## Dependencies 29 | 30 | schema depends on the following frameworks: 31 | 32 | * [Ionic](https://ionicframework.com/) - Cross-platform mobile app development 33 | * [node.js](https://nodejs.org/en/) - Cross-platform JavaScript run-time environment 34 | * [Chart.js](Chart.js) - Open source HTML5 charts 35 | 36 | ### Plugins 37 | 38 | schema uses the following Ionic Native plugins to achieve native functionality: 39 | 40 | | Plugin | Documentation | 41 | | ------ | ------ | 42 | | Barcode Scanner | [https://ionicframework.com/docs/native/barcode-scanner](https://ionicframework.com/docs/native/barcode-scanner) | 43 | | LocalNotifications | [https://ionicframework.com/docs/native/local-notifications](https://ionicframework.com/docs/native/local-notifications) | 44 | | SQLite | [https://ionicframework.com/docs/native/sqlite](https://ionicframework.com/docs/native/sqlite) | 45 | | FileTransfer | [https://ionicframework.com/docs/native/file-transfer](https://ionicframework.com/docs/native/file-transfer) | 46 | | File | [https://ionicframework.com/docs/native/file](https://ionicframework.com/docs/native/file) 47 | 48 | ## Installation 49 | 50 | Install the dependencies and platforms 51 | 52 | ```sh 53 | $ cd schema 54 | $ ionic cordova prepare 55 | ``` 56 | The [Ionic Docs](https://ionicframework.com/docs/installation/cli) contain detailed instructions on the next steps. 57 | 58 | ## Localisation 59 | schema uses the [ngx-translate](https://github.com/ngx-translate/core) library for translation. If you wish to contribute a localised version of the app's strings in another language, the file ```src/assets/i18n/en.json``` should be used as a template. 60 | 61 | ## Deploying a study 62 | To host your own study on the schema platform, the following steps are required: 63 | * Create a study protocol and upload it to a web server (follow the instructions below) 64 | * Create a page on a server to receive post requests and save data 65 | 66 | ### Study protocol 67 | A study protocol is defined in a JSON file. At the highest level, this file contains two attributes: the *properties* object which stores the metadata about the study; and the *modules* array which stores the individual survey/intervention tasks that will be delivered to the participants. 68 | 69 | ``` 70 | { 71 | "properties": { 72 | /* property attributes */ 73 | }, 74 | "modules": [ 75 | /* module objects */ 76 | ] 77 | } 78 | ``` 79 | We recommend using a GUI service like [JSON Editor Online](https://jsoneditoronline.org/) to build your study protocol. 80 | 81 | #### Properties 82 | The properties object must define the following attributes: 83 | 84 | | Property | Type | Description | Example | 85 | | ------ | ------ | ------ | ------ | 86 | | ```study_id``` | String | An identifier for the study which is sent to the server with response data. | ```"study_id":"ABC123"``` | 87 | | ```study_name``` | String | The name of the current study. | ```"study_name": "Sleep Study"``` | 88 | | ```instructions``` | String | Brief description/instructions for the study that is displayed in the app. Basic HTML supported. | ```"instructions": "This study will track your sleep."``` | 89 | | ```banner_url``` | String | The URL to an image that will be displayed on the home page of your study. It will be displayed at 100% width and maintain the aspect ratio of the original image. | ```"banner_url": "https://getschema.app/banner.png"``` | 90 | | ```support_email``` | String | An email address that participants can contact for support with the study. | ```"support_email": "support@getschema.app"``` | 91 | | ```support_url``` | String | A web link to the study's homepage or support information that is linked to in the app. | ```"support_url": "https://getschema.app/"``` | 92 | | ```ethics``` | String | An ethics statement for the study. | ```"ethics": "This study was approved by ethics committee with approval number 0093423"``` | 93 | | ```pls``` | String | A web URL to a PDF file containing the study's Plain Language Statement. | ```"pls": "https://getschema.app/pls.pdf"``` | 94 | | ```empty_msg``` | String | A message displayed to the user when there are no tasks currently available to complete. | ```"empty_msg": "Relax, you're all up to date."``` | 95 | | ```post_url``` | String | An endpoint to receive participant responses (POST data) from the app. | ```"post_url": "https://getschema.app/post.php"``` | 96 | | ```conditions``` | Array | A list of conditions that participants can be randomised into. | ```"conditions": [ "Control", "Intervention" ] ``` | 97 | | ```cache``` | Boolean | Indicates whether media elements will be cached for offline mode during study enrollment. Note: media should be optimised to reduce download times. | ```"cache": true ``` | 98 | 99 | #### Modules 100 | The modules array contains one or many module objects, which encapsulate the surveys and/or interventions that will be delivered to the participants of your study. A module object has this high-level structure: 101 | 102 | ``` 103 | { 104 | "type": "...", 105 | "name": "...", 106 | "submit_txt": "...", 107 | "condition": "...", 108 | "alerts": { 109 | /* alert properties */ 110 | }, 111 | "graph": { 112 | /* graph properties */ 113 | }, 114 | "sections": [ 115 | /* section objects with questions */ 116 | ] 117 | } 118 | ``` 119 | The properties of a module object are defined as follows: 120 | 121 | | Property | Type | Description | Example | 122 | | ------ | ------ | ------ | ------ | 123 | | ```type``` | String | The type of the module. Accepted values are ```survey```, ```info```, ```video```, and ```audio```. | ```"type": "survey"``` | 124 | | ```name``` | String | The name of the module. Basic HTML supported. | ```"name": "Daily Checklist"``` | 125 | | ```submit_txt``` | String | The label of the submit button for this module. Note: this value appears only on the final section of a module. | ```"submit_txt": "Finish"``` | 126 | | ```condition``` | String | The condition that this module belongs to. It must match one of the values from the ```conditions``` array from the study properties, or have the value ```*``` to be scheduled for all participants. | ```"condition":"Control"``` | 127 | | ```alerts``` | Object | Contains information about the scheduling of this module. Used to control access to the task and set notifications. | See *alerts*. | 128 | | ```graph``` | Object | Contains information about the graph relating to this module (if any). Used to render the graph in the Feedback tab. | See *graph*. | 129 | | ```sections``` | Array | An array of section objects that contain the questions/elements for this module. | See *sections*. | 130 | | ```uuid``` | String | A unique identifier for this module. | ```"uuid": "5f8c6ec7-463d-4e51-9ea3-480115bd9f53" ``` | 131 | | ```unlock_after``` | Array | A list of UUIDs of modules that must be completed before this module will appear on the task list. | ```"unlock_after": [ "b79bc562-1dd2-4c3f-a1ed-6bb359cbfaaa" ] ``` | 132 | | ```shuffle``` | Boolean | Used for counterbalancing. If ```true```, the order of the sections will be randomised every time the module is accessed. | ```"shuffle": true``` | 133 | 134 | ##### Alerts 135 | The alerts object must define the following attributes: 136 | 137 | | Property | Type | Description | Example | 138 | | ------ | ------ | ------ | ------ | 139 | | ```title``` | String | The title that is displayed in the notification (main text). | ```"title": "Time to check in!``` | 140 | | ```message``` | String | The message that is displayed in the notification (secondary text). | ```"message": "Tap here to open the app."``` | 141 | | ```start_offset``` | Integer | Indicates when the module should first be displayed to the user, where zero is the day that the participant enrolled. | ```"start_offset": 1``` | 142 | | ```duration``` | Integer | Indicates the number of consecutive days that the module should be scheduled to display. | ```"duration": 3``` | 143 | | ```times``` | Array | The times that this module should be scheduled for each day. ```hours``` indicates the hours (24-hour time) and ```minutes``` indicates the minutes (so should be between 0 and 59). | ```"times": [ { "hours": 8, "minutes": 30 } ]``` | 144 | | ```random``` | Boolean | Indicates whether the alert times should be randomised. If true, each value from ```times``` will be set using the value of ```random_interval```. | ```"random": true``` | 145 | | ```random_interval``` | Integer | The number of minutes before and after that an alert time should be randomised. For example, if the alert is scheduled for 8.30am and the ```random_interval``` is 30, the alert will be scheduled randomly between 8 and 9am. | ```"random_interval": 30``` | 146 | | ```sticky``` | boolean | Indicates whether the module should remain available in the Tasks list upon response, allowing the user to access this module repeatedly. | ```"sticky": true``` | 147 | | ```sticky_label``` | String | A title that appears above a sticky module on the home screen. Multiple sticky modules that are set to appear in succession will be grouped under this title. | ```"sticky_label": "Warm up videos"``` | 148 | | ```timeout``` | Boolean | If ```timeout``` is true, the task will disappear from the list after the number of milliseconds specified in ```timeout_after``` have elapsed (if the module is not completed before this time). | ```"timeout": true``` | 149 | | ```timeout_after``` | Integer | The number of milliseconds after a task is displayed that it will disappear from the list. ```timeout``` must be ```true``` for this to have any effect. | ```"timeout_after": 300000``` | 150 | 151 | ##### Graph 152 | The graphs object must define the following attributes: 153 | 154 | | Property | Type | Description | Example | 155 | | ------ | ------ | ------ | ------ | 156 | | ```display``` | Boolean | Indicates whether this module displays a feedback graph in the Feedback tab. If the value is ```false```, the remaining variables are ignored. | ```"display": true``` | 157 | | ```variable``` | String | The ```id``` of a question object to graph. It must match one of the module's question ids. | ```"variable": "q4"``` | 158 | | ```title``` | String | The title of the graph to be displayed in the Feedback tab. | ```"title": "Daily sleep"``` | 159 | | ```blurb``` | String | A brief description of the graph to be displayed below it in the feedback tab. Basic HTML supported. | ```"blurb": "Your daily sleep in hours"``` | 160 | | ```type``` | String | The type of graph. Currently ```bar``` and ```line``` are supported. | ```"type": "line"``` | 161 | | ```max_points``` | Integer | The maximum number of data points to display in the graph, e.g. ```10``` will only show the ten most recent responses. | ```"max_points": 10``` | 162 | 163 | ##### Sections 164 | The sections array contains one or many section objects, which have this high level structure: 165 | 166 | { 167 | "name": "Demographics", 168 | "questions": [ 169 | /* question objects */ 170 | ] 171 | } 172 | 173 | The properties are defined as follows: 174 | 175 | | Property | Type | Description | Example | 176 | | ------ | ------ | ------ | ------ | 177 | | ```name``` | String | The title of this section, which is displayed at the top of the screen. | ```"name": "Demographics"``` | 178 | | ```questions``` | Array | An array containing all of the questions for this section of the module. | See *questions*. | 179 | | ```shuffle``` | Boolean | Used for counterbalancing. If ```true```, the order of the questions in this section will be randomised. | ```"shuffle": true``` | 180 | 181 | #### Questions 182 | There are several types of question object that can be added to a section, including: 183 | 184 | * Instruction 185 | * Text Input 186 | * Date/Time 187 | * Yes/No (boolean) 188 | * Slider 189 | * Multiple Choice 190 | * Media 191 | 192 | All question objects must include the following properties: 193 | 194 | | Property | Type | Description | Example | 195 | | ------ | ------ | ------ | ------ | 196 | | ```id``` | String | A unique id to identify this question. This id is sent to the server along with any response value. Note: Every element in the entire study protocol must have a unique ```id``` for some features to function correctly. | ```"id": "q1"``` | 197 | | ```type``` | String | The primary type of this question. Accepted values are ```instruction```, ```datetime```, ```multi```, ```text```, ```slider```, ```video```, ```audio```, and ```yesno```. | ```"type": "slider"``` | 198 | | ```text``` | String | The label displayed alongside the question. Basic HTML supported. | ```"text": "How do you feel?"``` | 199 | | ```required``` | Boolean | Denotes whether this question is required to be answered. The app will force the participant to answer all required questions that are not hidden by branching. | ```"required": true``` | 200 | 201 | Many question types have additional properties that they must include, which are outlined in the following sections. 202 | 203 | ##### Text Input 204 | Text Input questions must have the following additional property: 205 | 206 | | Property | Type | Description | Example | 207 | | ------ | ------ | ------ | ------ | 208 | | ```subtype``` | String | The specific type of text input for this field. Accepted values are ```short```, ```long```, and ```numeric```. | ```"subtype": "long"``` | 209 | 210 | ##### Date/Time 211 | Date/Time questions must have the following additional property: 212 | 213 | | Property | Type | Description | Example | 214 | | ------ | ------ | ------ | ------ | 215 | | ```subtype``` | String | The specific type of date/time input for this field. Accepted values are ```date``` (datepicker only), ```time``` (timepicker only), and ```datetime``` (both). | ```"subtype": "time"``` | 216 | 217 | ##### Yes/No 218 | Yes/No questions must have the following additional properties: 219 | 220 | | Property | Type | Description | Example | 221 | | ------ | ------ | ------ | ------ | 222 | | ```yes_text``` | String | The label for a true/yes response. | ```"yes_text": "Agree"``` | 223 | | ```no_text``` | String | The label for a false/no response. | ```"no_text": "Disagree"``` | 224 | 225 | ##### Slider 226 | Slider questions must have the following additional properties: 227 | 228 | | Property | Type | Description | Example | 229 | | ------ | ------ | ------ | ------ | 230 | | ```min``` | Integer | The minimum value for the slider range. | ```"min": 0``` | 231 | | ```max``` | Integer | The maximum value for the slider range. | ```"max": 100``` | 232 | | ```hint_left``` | String | A label displayed to the left of the slider. | ```"hint_left": "less"``` | 233 | | ```hint_right``` | String | A label displayed to the right of the slider. | ```"hint_right": "more"``` | 234 | 235 | ##### Multiple Choice 236 | Multiple choice questions must have the following additional properties: 237 | 238 | | Property | Type | Description | Example | 239 | | ------ | ------ | ------ | ------ | 240 | | ```radio``` | Boolean | Denotes whether the multiple choice should be radio buttons (one selection only) or checkboxes (multiple selections allowed). | ```"radio": true``` | 241 | | ```modal``` | Boolean | Denotes whether the selections should appear in a modal popup (good for longer lists) | ```"modal": false``` | 242 | | ```options``` | Array | The list of choices to display. | ```"options": [ "Dog", "Cat", "Fish" ]``` | 243 | | ```shuffle``` | Boolean | If ```true```, the order of the choices will be randomly shuffled. | ```"shuffle": true``` | 244 | 245 | ##### Media 246 | Media questions must have the following additional properties: 247 | 248 | | Property | Type | Description | Example | 249 | | ------ | ------ | ------ | ------ | 250 | | ```subtype``` | String | The type of media. Accepted values are ```video```, ```audio```, and ```image```. | ```"subtype": "video"``` | 251 | | ```src``` | String | A direct URL to the media source. | ```"src": "https://getschema.app/video.mp4"``` | 252 | | ```thumb``` | String | Required for ```video``` elements. A direct URL to the placeholder image that is displayed in the video player while loading. | ```"thumb": "https://getschema.app/thumbnail.png"``` | 253 | 254 | #### Branching 255 | To use branching, you need to add two additional properties to the question object that is to be dynamically shown/hidden. 256 | 257 | | Property | Type | Description | Example | 258 | | ------ | ------ | ------ | ------ | 259 | | ```hide_id``` | String | The ```id``` of the question that will trigger this question to dynamically show/hide. | ```"hide_id": "q5"``` | 260 | | ```hide_value``` | String/Boolean | The value that needs to be selected in the question denoted by ```hide_id``` which will make this question appear. When using sliders, the value should be prefixed with a direction and is inclusive, e.g. ```>50``` or ```<50```. | ```"hide_value": "10"``` | 261 | | ```hide_if``` | Boolean | Indicates the branching behaviour. If ```true```, the element will disappear if the value of the question equals ```hide_value```. If false, the element will appear appear instead. | ```"hide_if": false``` | 262 | 263 | Currently, branching is supported by the ```multi```, ```yesno```, and ```slider``` question types. 264 | 265 | #### Randomisation of elements 266 | Elements can also be grouped for randomisation, such that every time a module is accessed only one of the random elements will be displayed. An example use case would be to display a random image from a set of images. To achieve this, add the following property to each group of elements: 267 | 268 | | Property | Type | Description | Example | 269 | | ------ | ------ | ------ | ------ | 270 | | ```rand_group``` | String | An identifier that groups a set of elements together so that only one will randomly appear every time a module is accessed. Note: To identify which element was visible, it will be given a response value of ```1```. If the element can record a response this value will be replaced with that response. All hidden elements will record no response. | ```"rand_group": "sad_images"``` | 271 | 272 | ### Collecting data 273 | The ```post_url``` defined in the study protocol's properties object should point to an endpoint that can receive POST requests. The endpoint should return the boolean value ```true``` if data has been successfully saved - schema will continue submitting each data point to the server until it receives this acknowledgement. 274 | 275 | schema posts the following variables to the server whenever a task is completed: 276 | 277 | | POST id | Type | Description | 278 | | ------ | ------ | ------ | 279 | | ```data_type``` | String | Describes whether ```log``` or ```survey_response``` data is being submitted. | 280 | | ```study_id``` | String | The identifier of the study taken from the ```study_id``` property of the study protocol. | 281 | | ```user_id``` | String | The unique id of the user. | 282 | | ```module_index``` | Integer | The index of the module in the ```modules``` array (zero-based). | 283 | | ```platform``` | String | The platform the user responded on. Value will be ```iphone```, ```ipad``` or ```android```. | 284 | 285 | For ```survey_response``` data, these additional variables are included: 286 | 287 | | POST id | Type | Description | 288 | | ------ | ------ | ------ | 289 | | ```module_name``` | String | The name of the module. | 290 | | ```responses``` | String | The questions responses for this task, provided as a stringified JSON object. The key is the ```id``` of the question, for example ```{ "q1": 56 , "q2": "No", "q3": "" }```. | 291 | | ```response_time``` | Timestamp | The timestamp when the module was completed, in the user's local time, e.g. ```2019-05-08T23:16:21+10:00```. | 292 | | ```alert_time``` | Timestamp | The timestamp when the module was first scheduled to appear, e.g. ```2019-05-08T23:00:21+10:00```. | 293 | 294 | For ```log``` data, these additional variables are included: 295 | 296 | | POST id | Type | Description | 297 | | ------ | ------ | ------ | 298 | | ```page``` | String | The page the user visited in the app. Value can be ```home```, ```my-progress```, ```settings```, or ```survey```. If ```survey```, the ```module_index``` variable will differentiate which module was accessed. | 299 | | ```timestamp``` | Timestamp | The timestamp when the user visited the page, e.g. ```2019-10-29T16:08:58+11:00```. | 300 | 301 | ### Distribution 302 | Participants can sign up to your study by scanning a QR code or entering a URL. Upload your JSON study protocol to a web server and distribute the link. We recommend using a service like [QRCode Monkey](https://www.qrcode-monkey.com/) to generate a QR code that points to your study protocol link. The URL can be shortened for distribution using [Bitly](https://bitly.com/). 303 | 304 | # Testing your program 305 | The variability in devices that support mHealth apps and the diversity of possible research designs within schema may result in unintended bugs. Therefore it is important that you conduct thorough testing of any program you deploy to schema before sharing it with participants. 306 | 307 | ## Feedback 308 | Please post any bugs, issues or suggested features in the Issues tab. 309 | 310 | # Localisation 311 | schema uses the [ngx-translate](https://github.com/ngx-translate/core) library for translation. If you wish to contribute a localised version of the app's strings in another language, the file ```src/assets/i18n/en.json``` should be used as a template. Follow the documentation from ngx-translate to determine the correct name for your language file. 312 | 313 | License 314 | ---- 315 | 316 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 317 | -------------------------------------------------------------------------------- /_examples/demo_database_install.php: -------------------------------------------------------------------------------- 1 | connect_error) { 13 | die("Connection failed: " . $conn->connect_error); 14 | } 15 | return $conn; 16 | } 17 | 18 | private function closeDB($conn) { 19 | $conn->close(); 20 | } 21 | 22 | private function createDB($conn) { 23 | $sql = "CREATE DATABASE IF NOT EXISTS schema_datastore"; 24 | if ($conn->query($sql) === TRUE) 25 | return true; 26 | return false; 27 | } 28 | 29 | private function createDataTable($conn) { 30 | mysqli_select_db($conn,"schema_datastore"); 31 | 32 | $sql = "CREATE TABLE data ( 33 | data_id INT(11) UNSIGNED AUTO_INCREMENT PRIMARY KEY, 34 | study_id VARCHAR(30) NOT NULL, 35 | user_id VARCHAR(30) NOT NULL, 36 | module_index INT(11) NOT NULL, 37 | module_name VARCHAR(500) NOT NULL, 38 | responses VARCHAR(10000) NOT NULL, 39 | response_time VARCHAR(1000) NOT NULL, 40 | alert_time VARCHAR(1000) NOT NULL, 41 | platform VARCHAR(50) NOT NULL 42 | )"; 43 | 44 | if ($conn->query($sql) === TRUE) 45 | return true; 46 | return false; 47 | } 48 | 49 | private function createLogTable($conn) { 50 | mysqli_select_db($conn,"schema_datastore"); 51 | 52 | $sql = "CREATE TABLE logs ( 53 | log_id INT(11) UNSIGNED AUTO_INCREMENT PRIMARY KEY, 54 | study_id VARCHAR(30) NOT NULL, 55 | user_id VARCHAR(30) NOT NULL, 56 | module_index INT(11) NOT NULL, 57 | page VARCHAR(100) NOT NULL, 58 | timestamp VARCHAR(500) NOT NULL, 59 | platform VARCHAR(50) NOT NULL 60 | )"; 61 | 62 | if ($conn->query($sql) === TRUE) 63 | return true; 64 | return false; 65 | } 66 | 67 | function setupDB() { 68 | $conn = $this->connect(); 69 | 70 | $db = $this->createDB($conn); 71 | 72 | if ($db) { 73 | echo "Database created successfully"; 74 | $dataTableCreated = $this->createDataTable($conn); 75 | $logsTableCreated = $this->createLogTable($conn); 76 | } else { 77 | echo "There was an error creating the database"; 78 | } 79 | 80 | if ($dataTableCreated) 81 | echo "Data table created successfully"; 82 | else 83 | echo "Error creating data table"; 84 | 85 | if ($logsTableCreated) 86 | echo "Logs table created successfully"; 87 | else 88 | echo "Error creating logs table"; 89 | 90 | $this->closeDB($conn); 91 | } 92 | } 93 | 94 | $dbManager = new SchemaDBManager(); 95 | $dbManager->setupDB(); 96 | 97 | ?> -------------------------------------------------------------------------------- /_examples/demo_post.php: -------------------------------------------------------------------------------- 1 | $value) { 7 | file_put_contents('data.txt', $key . ':' . $value . '; ', FILE_APPEND); 8 | } 9 | 10 | file_put_contents('data.txt', "\n", FILE_APPEND); 11 | 12 | echo true; 13 | ?> 14 | -------------------------------------------------------------------------------- /_examples/demo_protocol.json: -------------------------------------------------------------------------------- 1 | { 2 | "properties": { 3 | "study_name": "Demo", 4 | "study_id": "3ZDOGAH", 5 | "created_by": "Adrian Shatte", 6 | "instructions": "This is a demo study showing the features of schema", 7 | "post_url": "https://getschema.app/demo_post.php", 8 | "empty_msg": "You're all up to date", 9 | "banner_url": "https://getschema.app/img/schema_banner.png", 10 | "support_url": "https://getschema.app", 11 | "support_email": "hello@getschema.app", 12 | "conditions": [ 13 | "Control", 14 | "Treatment" 15 | ], 16 | "cache": false, 17 | "ethics": "This study was approved by ethics body with approval #123456789", 18 | "pls": "https://getschema.app/pls-file-link.pdf" 19 | }, 20 | "modules": [ 21 | { 22 | "type": "info", 23 | "name": "Welcome", 24 | "submit_text": "Submit", 25 | "alerts": { 26 | "title": "Welcome to the study", 27 | "message": "Tap to open the app", 28 | "duration": 1, 29 | "times": [ 30 | { 31 | "hours": 8, 32 | "minutes": 30 33 | } 34 | ], 35 | "random": true, 36 | "random_interval": 30, 37 | "sticky": true, 38 | "sticky_label": "Start here", 39 | "timeout": false, 40 | "timeout_after": 0, 41 | "start_offset": 0 42 | }, 43 | "graph": { 44 | "display": false 45 | }, 46 | "sections": [ 47 | { 48 | "name": "Welcome", 49 | "questions": [ 50 | { 51 | "id": "instruction-1wnjocfw", 52 | "type": "instruction", 53 | "text": "Hello! Welcome to the study! This module only shows for those enrolled in the control condition.", 54 | "required": false, 55 | "hide_id": "", 56 | "hide_value": "", 57 | "hide_if": true 58 | } 59 | ], 60 | "shuffle": false 61 | } 62 | ], 63 | "shuffle": false, 64 | "condition": "Control", 65 | "uuid": "3fb09fcd-4fca-4074-a395-34d65ee5a521", 66 | "unlock_after": [] 67 | }, 68 | { 69 | "type": "survey", 70 | "name": "Elements", 71 | "submit_text": "Submit", 72 | "alerts": { 73 | "title": "Elements Demo", 74 | "message": "Tap to open app", 75 | "duration": 5, 76 | "times": [ 77 | { 78 | "hours": 9, 79 | "minutes": 30 80 | }, 81 | { 82 | "hours": 12, 83 | "minutes": 30 84 | }, 85 | { 86 | "hours": 15, 87 | "minutes": 30 88 | }, 89 | { 90 | "hours": 18, 91 | "minutes": 30 92 | } 93 | ], 94 | "random": true, 95 | "random_interval": 30, 96 | "sticky": false, 97 | "sticky_label": "", 98 | "timeout": true, 99 | "timeout_after": 30, 100 | "start_offset": 1 101 | }, 102 | "graph": { 103 | "display": true, 104 | "title": "Slider Graph", 105 | "blurb": "This graph displays the values from the slider element as a bar graph, displaying the past 7 responses.", 106 | "variable": "slider-0yih1evt", 107 | "type": "bar", 108 | "max_points": 7 109 | }, 110 | "sections": [ 111 | { 112 | "name": "Section 1", 113 | "questions": [ 114 | { 115 | "id": "instruction-pvke1yey", 116 | "type": "instruction", 117 | "text": "This is an instruction type.", 118 | "required": false, 119 | "hide_id": "", 120 | "hide_value": "", 121 | "hide_if": true 122 | }, 123 | { 124 | "id": "text-71nnpqzi", 125 | "type": "text", 126 | "text": "This is a text input type.", 127 | "required": true, 128 | "hide_id": "", 129 | "hide_value": "", 130 | "hide_if": true, 131 | "subtype": "short" 132 | }, 133 | { 134 | "id": "datetime-79ygddzl", 135 | "type": "datetime", 136 | "text": "This is a date input type (date only).", 137 | "required": true, 138 | "hide_id": "", 139 | "hide_value": "", 140 | "hide_if": true, 141 | "subtype": "date" 142 | }, 143 | { 144 | "id": "multi-q8bohlar", 145 | "type": "multi", 146 | "text": "This is a multiple choice type with branching demo.", 147 | "required": true, 148 | "hide_id": "", 149 | "hide_value": "", 150 | "hide_if": true, 151 | "modal": false, 152 | "radio": true, 153 | "shuffle": true, 154 | "options": [ 155 | "apple", 156 | "orange", 157 | "banana" 158 | ] 159 | }, 160 | { 161 | "id": "instruction-mof4ymv4", 162 | "type": "instruction", 163 | "text": "This will only show if the user selects banana from the previous question", 164 | "required": false, 165 | "hide_id": "multi-q8bohlar", 166 | "hide_value": "banana", 167 | "hide_if": false 168 | } 169 | ], 170 | "shuffle": false 171 | }, 172 | { 173 | "name": "Section 2", 174 | "questions": [ 175 | { 176 | "id": "media-o3p069gi", 177 | "type": "media", 178 | "text": "This is a media type.", 179 | "required": false, 180 | "hide_id": "", 181 | "hide_value": "", 182 | "hide_if": true, 183 | "subtype": "image", 184 | "src": "https://getschema.app/img/schema_banner.jpg", 185 | "thumb": "" 186 | }, 187 | { 188 | "id": "slider-0yih1evt", 189 | "type": "slider", 190 | "text": "This is a slider type", 191 | "required": true, 192 | "hide_id": "", 193 | "hide_value": "", 194 | "hide_if": true, 195 | "min": 0, 196 | "max": 10, 197 | "hint_left": "less", 198 | "hint_right": "more" 199 | }, 200 | { 201 | "id": "yesno-mv09ggb1", 202 | "type": "yesno", 203 | "text": "This is a switch", 204 | "required": true, 205 | "hide_id": "", 206 | "hide_value": "", 207 | "hide_if": true, 208 | "yes_text": "Yes", 209 | "no_text": "No" 210 | } 211 | ], 212 | "shuffle": false 213 | } 214 | ], 215 | "shuffle": false, 216 | "condition": "*", 217 | "uuid": "dee87a08-8616-453a-9a6e-9e8f8ea9c942", 218 | "unlock_after": [] 219 | } 220 | ] 221 | } 222 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular-devkit/core/src/workspace/workspace-schema.json", 3 | "version": 1, 4 | "defaultProject": "app", 5 | "newProjectRoot": "projects", 6 | "projects": { 7 | "app": { 8 | "root": "", 9 | "sourceRoot": "src", 10 | "projectType": "application", 11 | "prefix": "app", 12 | "schematics": {}, 13 | "architect": { 14 | "build": { 15 | "builder": "@angular-devkit/build-angular:browser", 16 | "options": { 17 | "outputPath": "www", 18 | "index": "src/index.html", 19 | "main": "src/main.ts", 20 | "polyfills": "src/polyfills.ts", 21 | "tsConfig": "src/tsconfig.app.json", 22 | "assets": [ 23 | { 24 | "glob": "**/*", 25 | "input": "src/assets", 26 | "output": "assets" 27 | }, 28 | { 29 | "glob": "**/*.svg", 30 | "input": "node_modules/ionicons/dist/ionicons/svg", 31 | "output": "./svg" 32 | } 33 | ], 34 | "styles": [ 35 | { 36 | "input": "src/theme/variables.scss" 37 | }, 38 | { 39 | "input": "src/global.scss" 40 | } 41 | ], 42 | "scripts": [], 43 | "es5BrowserSupport": true 44 | }, 45 | "configurations": { 46 | "production": { 47 | "fileReplacements": [ 48 | { 49 | "replace": "src/environments/environment.ts", 50 | "with": "src/environments/environment.prod.ts" 51 | } 52 | ], 53 | "optimization": true, 54 | "outputHashing": "all", 55 | "sourceMap": false, 56 | "extractCss": true, 57 | "namedChunks": false, 58 | "aot": true, 59 | "extractLicenses": true, 60 | "vendorChunk": false, 61 | "buildOptimizer": true, 62 | "budgets": [ 63 | { 64 | "type": "initial", 65 | "maximumWarning": "2mb", 66 | "maximumError": "5mb" 67 | } 68 | ] 69 | }, 70 | "ci": { 71 | "progress": false 72 | } 73 | } 74 | }, 75 | "serve": { 76 | "builder": "@angular-devkit/build-angular:dev-server", 77 | "options": { 78 | "browserTarget": "app:build" 79 | }, 80 | "configurations": { 81 | "production": { 82 | "browserTarget": "app:build:production" 83 | }, 84 | "ci": { 85 | "progress": false 86 | } 87 | } 88 | }, 89 | "extract-i18n": { 90 | "builder": "@angular-devkit/build-angular:extract-i18n", 91 | "options": { 92 | "browserTarget": "app:build" 93 | } 94 | }, 95 | "test": { 96 | "builder": "@angular-devkit/build-angular:karma", 97 | "options": { 98 | "main": "src/test.ts", 99 | "polyfills": "src/polyfills.ts", 100 | "tsConfig": "src/tsconfig.spec.json", 101 | "karmaConfig": "src/karma.conf.js", 102 | "styles": [], 103 | "scripts": [], 104 | "assets": [ 105 | { 106 | "glob": "favicon.ico", 107 | "input": "src/", 108 | "output": "/" 109 | }, 110 | { 111 | "glob": "**/*", 112 | "input": "src/assets", 113 | "output": "/assets" 114 | } 115 | ] 116 | }, 117 | "configurations": { 118 | "ci": { 119 | "progress": false, 120 | "watch": false 121 | } 122 | } 123 | }, 124 | "lint": { 125 | "builder": "@angular-devkit/build-angular:tslint", 126 | "options": { 127 | "tsConfig": ["src/tsconfig.app.json", "src/tsconfig.spec.json"], 128 | "exclude": ["**/node_modules/**"] 129 | } 130 | }, 131 | "ionic-cordova-build": { 132 | "builder": "@ionic/angular-toolkit:cordova-build", 133 | "options": { 134 | "browserTarget": "app:build" 135 | }, 136 | "configurations": { 137 | "production": { 138 | "browserTarget": "app:build:production" 139 | } 140 | } 141 | }, 142 | "ionic-cordova-serve": { 143 | "builder": "@ionic/angular-toolkit:cordova-serve", 144 | "options": { 145 | "cordovaBuildTarget": "app:ionic-cordova-build", 146 | "devServerTarget": "app:serve" 147 | }, 148 | "configurations": { 149 | "production": { 150 | "cordovaBuildTarget": "app:ionic-cordova-build:production", 151 | "devServerTarget": "app:serve:production" 152 | } 153 | } 154 | } 155 | } 156 | }, 157 | "app-e2e": { 158 | "root": "e2e/", 159 | "projectType": "application", 160 | "architect": { 161 | "e2e": { 162 | "builder": "@angular-devkit/build-angular:protractor", 163 | "options": { 164 | "protractorConfig": "e2e/protractor.conf.js", 165 | "devServerTarget": "app:serve" 166 | }, 167 | "configurations": { 168 | "ci": { 169 | "devServerTarget": "app:serve:ci" 170 | } 171 | } 172 | }, 173 | "lint": { 174 | "builder": "@angular-devkit/build-angular:tslint", 175 | "options": { 176 | "tsConfig": "e2e/tsconfig.e2e.json", 177 | "exclude": ["**/node_modules/**"] 178 | } 179 | } 180 | } 181 | } 182 | }, 183 | "cli": { 184 | "defaultCollection": "@ionic/angular-toolkit" 185 | }, 186 | "schematics": { 187 | "@ionic/angular-toolkit:component": { 188 | "styleext": "scss" 189 | }, 190 | "@ionic/angular-toolkit:page": { 191 | "styleext": "scss" 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | schema 4 | A survey/intervention platform. 5 | Adrian Shatte and Sam Teague 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 | Scan QR code to enrol in study. 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /ionic.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "schema2", 3 | "integrations": { 4 | "cordova": {} 5 | }, 6 | "type": "angular" 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "schema2", 3 | "version": "0.0.1", 4 | "author": "Ionic Framework", 5 | "homepage": "https://ionicframework.com/", 6 | "scripts": { 7 | "ng": "ng", 8 | "start": "ng serve", 9 | "build": "ng build", 10 | "test": "ng test", 11 | "lint": "ng lint", 12 | "e2e": "ng e2e" 13 | }, 14 | "private": true, 15 | "dependencies": { 16 | "@angular/common": "~8.2.14", 17 | "@angular/core": "~8.2.14", 18 | "@angular/forms": "~8.2.14", 19 | "@angular/http": "^7.2.2", 20 | "@angular/platform-browser": "~8.2.14", 21 | "@angular/platform-browser-dynamic": "~8.2.14", 22 | "@angular/router": "~8.2.14", 23 | "@ionic-native/barcode-scanner": "^5.4.0", 24 | "@ionic-native/core": "^5.0.7", 25 | "@ionic-native/file": "^5.6.0", 26 | "@ionic-native/file-transfer": "^5.6.0", 27 | "@ionic-native/http": "^5.5.1", 28 | "@ionic-native/in-app-browser": "^5.15.1", 29 | "@ionic-native/local-notifications": "^5.4.0", 30 | "@ionic-native/media": "^5.6.1", 31 | "@ionic-native/splash-screen": "^5.0.0", 32 | "@ionic-native/status-bar": "^5.0.0", 33 | "@ionic-native/streaming-media": "^5.6.1", 34 | "@ionic/angular": "^5.0.0", 35 | "@ionic/storage": "^2.2.0", 36 | "@ngx-translate/core": "^12.1.2", 37 | "@ngx-translate/http-loader": "^4.0.0", 38 | "chart.js": "^2.9.4", 39 | "cordova-android": "8.0.0", 40 | "cordova-ios": "^5.1.1", 41 | "cordova-plugin-advanced-http": "2.0.9", 42 | "cordova-plugin-badge": "^0.8.8", 43 | "cordova-plugin-file": "6.0.1", 44 | "cordova-plugin-file-transfer": "1.7.1", 45 | "cordova-plugin-inappbrowser": "^3.2.0", 46 | "cordova-plugin-ionic-keyboard": "2.2.0", 47 | "cordova-plugin-ionic-webview": "^4.2.1", 48 | "cordova-plugin-local-notification": "^0.9.0-beta.3", 49 | "cordova-sqlite-storage": "3.2.0", 50 | "core-js": "^2.5.4", 51 | "io": "1.0.0", 52 | "ionic-cache-src": "^0.1.1", 53 | "moment": "^2.24.0", 54 | "ng2-charts": "^2.2.2", 55 | "phonegap-plugin-barcodescanner": "8.0.1", 56 | "rm": "0.1.8", 57 | "rxjs": "~6.5.1", 58 | "sass-loader": "^7.1.0", 59 | "tslib": "^1.9.0", 60 | "zone.js": "~0.9.1" 61 | }, 62 | "devDependencies": { 63 | "@angular-devkit/build-angular": "~0.803.20", 64 | "@angular-devkit/architect": "^0.901.4", 65 | "@angular-devkit/core": "^8.3.23", 66 | "@angular-devkit/schematics": "^8.3.23", 67 | "@angular/cli": "~8.3.23", 68 | "@angular/compiler": "~8.2.14", 69 | "@angular/compiler-cli": "~8.2.14", 70 | "@angular/language-service": "~8.2.14", 71 | "@ionic/angular-toolkit": "^2.1.1", 72 | "@types/jasmine": "~3.3.8", 73 | "@types/jasminewd2": "~2.0.3", 74 | "@types/node": "~8.9.4", 75 | "codelyzer": "^5.0.0", 76 | "cordova-plugin-device": "^2.0.2", 77 | "cordova-plugin-splashscreen": "^5.0.2", 78 | "cordova-plugin-statusbar": "^2.4.2", 79 | "cordova-plugin-whitelist": "^1.3.3", 80 | "jasmine-core": "~3.4.0", 81 | "jasmine-spec-reporter": "~4.2.1", 82 | "karma": "~6.3.16", 83 | "karma-chrome-launcher": "~2.2.0", 84 | "karma-coverage-istanbul-reporter": "~2.0.1", 85 | "karma-jasmine": "~2.0.1", 86 | "karma-jasmine-html-reporter": "^1.4.0", 87 | "protractor": "~5.4.0", 88 | "ts-node": "~7.0.0", 89 | "tslint": "~5.15.0", 90 | "typescript": "~3.4.3" 91 | }, 92 | "description": "An Ionic project", 93 | "cordova": { 94 | "plugins": { 95 | "phonegap-plugin-barcodescanner": {}, 96 | "cordova-plugin-whitelist": {}, 97 | "cordova-plugin-statusbar": {}, 98 | "cordova-plugin-device": {}, 99 | "cordova-plugin-splashscreen": {}, 100 | "cordova-sqlite-storage": {}, 101 | "cordova-plugin-advanced-http": {}, 102 | "cordova-plugin-file": {}, 103 | "cordova-plugin-file-transfer": {}, 104 | "cordova-plugin-ionic-keyboard": {}, 105 | "cordova-plugin-inappbrowser": {}, 106 | "cordova-plugin-local-notification": {}, 107 | "cordova-plugin-ionic-webview": { 108 | "ANDROID_SUPPORT_ANNOTATIONS_VERSION": "27.+" 109 | } 110 | }, 111 | "platforms": [ 112 | "android", 113 | "ios" 114 | ] 115 | } 116 | } -------------------------------------------------------------------------------- /resources/README.md: -------------------------------------------------------------------------------- 1 | These are Cordova resources. You can replace icon.png and splash.png and run 2 | `ionic cordova resources` to generate custom icons and splash screens for your 3 | app. See `ionic cordova resources --help` for details. 4 | 5 | Cordova reference documentation: 6 | 7 | - Icons: https://cordova.apache.org/docs/en/latest/config_ref/images.html 8 | - Splash Screens: https://cordova.apache.org/docs/en/latest/reference/cordova-plugin-splashscreen/ 9 | -------------------------------------------------------------------------------- /resources/android/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/icon.png -------------------------------------------------------------------------------- /resources/android/icon.png.md5: -------------------------------------------------------------------------------- 1 | 257e6dfdda4ec29ffd12049de807aad9 -------------------------------------------------------------------------------- /resources/android/icon/drawable-hdpi-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/icon/drawable-hdpi-icon.png -------------------------------------------------------------------------------- /resources/android/icon/drawable-ldpi-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/icon/drawable-ldpi-icon.png -------------------------------------------------------------------------------- /resources/android/icon/drawable-mdpi-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/icon/drawable-mdpi-icon.png -------------------------------------------------------------------------------- /resources/android/icon/drawable-xhdpi-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/icon/drawable-xhdpi-icon.png -------------------------------------------------------------------------------- /resources/android/icon/drawable-xxhdpi-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/icon/drawable-xxhdpi-icon.png -------------------------------------------------------------------------------- /resources/android/icon/drawable-xxxhdpi-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/icon/drawable-xxxhdpi-icon.png -------------------------------------------------------------------------------- /resources/android/notification-icon/icon-hdpi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/notification-icon/icon-hdpi.png -------------------------------------------------------------------------------- /resources/android/notification-icon/icon-mdpi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/notification-icon/icon-mdpi.png -------------------------------------------------------------------------------- /resources/android/notification-icon/icon-xhdpi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/notification-icon/icon-xhdpi.png -------------------------------------------------------------------------------- /resources/android/notification-icon/icon-xxhdpi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/notification-icon/icon-xxhdpi.png -------------------------------------------------------------------------------- /resources/android/notification-icon/icon-xxxhdpi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/notification-icon/icon-xxxhdpi.png -------------------------------------------------------------------------------- /resources/android/small-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/small-icon.png -------------------------------------------------------------------------------- /resources/android/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/splash.png -------------------------------------------------------------------------------- /resources/android/splash.png.md5: -------------------------------------------------------------------------------- 1 | a7ca94922f62339d26e9f75cab84cf73 -------------------------------------------------------------------------------- /resources/android/splash/drawable-land-hdpi-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/splash/drawable-land-hdpi-screen.png -------------------------------------------------------------------------------- /resources/android/splash/drawable-land-ldpi-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/splash/drawable-land-ldpi-screen.png -------------------------------------------------------------------------------- /resources/android/splash/drawable-land-mdpi-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/splash/drawable-land-mdpi-screen.png -------------------------------------------------------------------------------- /resources/android/splash/drawable-land-xhdpi-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/splash/drawable-land-xhdpi-screen.png -------------------------------------------------------------------------------- /resources/android/splash/drawable-land-xxhdpi-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/splash/drawable-land-xxhdpi-screen.png -------------------------------------------------------------------------------- /resources/android/splash/drawable-land-xxxhdpi-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/splash/drawable-land-xxxhdpi-screen.png -------------------------------------------------------------------------------- /resources/android/splash/drawable-port-hdpi-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/splash/drawable-port-hdpi-screen.png -------------------------------------------------------------------------------- /resources/android/splash/drawable-port-ldpi-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/splash/drawable-port-ldpi-screen.png -------------------------------------------------------------------------------- /resources/android/splash/drawable-port-mdpi-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/splash/drawable-port-mdpi-screen.png -------------------------------------------------------------------------------- /resources/android/splash/drawable-port-xhdpi-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/splash/drawable-port-xhdpi-screen.png -------------------------------------------------------------------------------- /resources/android/splash/drawable-port-xxhdpi-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/splash/drawable-port-xxhdpi-screen.png -------------------------------------------------------------------------------- /resources/android/splash/drawable-port-xxxhdpi-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/android/splash/drawable-port-xxxhdpi-screen.png -------------------------------------------------------------------------------- /resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/icon.png -------------------------------------------------------------------------------- /resources/icon.png.md5: -------------------------------------------------------------------------------- 1 | 3f1bbdf1aefcb5ce7b60770ce907c68f -------------------------------------------------------------------------------- /resources/ios/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/icon.png -------------------------------------------------------------------------------- /resources/ios/icon.png.md5: -------------------------------------------------------------------------------- 1 | adb81048170d8a20c4d52adcf2b5de6e -------------------------------------------------------------------------------- /resources/ios/icon/icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/icon/icon-1024.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/icon/icon-40.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/icon/icon-40@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/icon/icon-40@3x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/icon/icon-50.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-50@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/icon/icon-50@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/icon/icon-60.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/icon/icon-60@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/icon/icon-60@3x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/icon/icon-72.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-72@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/icon/icon-72@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/icon/icon-76.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/icon/icon-76@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/icon/icon-83.5@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/icon/icon-small.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-small@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/icon/icon-small@2x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon-small@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/icon/icon-small@3x.png -------------------------------------------------------------------------------- /resources/ios/icon/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/icon/icon.png -------------------------------------------------------------------------------- /resources/ios/icon/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/icon/icon@2x.png -------------------------------------------------------------------------------- /resources/ios/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/splash.png -------------------------------------------------------------------------------- /resources/ios/splash.png.md5: -------------------------------------------------------------------------------- 1 | a7ca94922f62339d26e9f75cab84cf73 -------------------------------------------------------------------------------- /resources/ios/splash/Default-568h@2x~iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/splash/Default-568h@2x~iphone.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-667h.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/splash/Default-667h.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-736h.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/splash/Default-736h.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-Landscape-736h.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/splash/Default-Landscape-736h.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-Landscape@2x~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/splash/Default-Landscape@2x~ipad.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-Landscape@~ipadpro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/splash/Default-Landscape@~ipadpro.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-Landscape~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/splash/Default-Landscape~ipad.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-Portrait@2x~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/splash/Default-Portrait@2x~ipad.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-Portrait@~ipadpro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/splash/Default-Portrait@~ipadpro.png -------------------------------------------------------------------------------- /resources/ios/splash/Default-Portrait~ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/splash/Default-Portrait~ipad.png -------------------------------------------------------------------------------- /resources/ios/splash/Default@2x~iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/splash/Default@2x~iphone.png -------------------------------------------------------------------------------- /resources/ios/splash/Default@2x~universal~anyany.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/splash/Default@2x~universal~anyany.png -------------------------------------------------------------------------------- /resources/ios/splash/Default~iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/ios/splash/Default~iphone.png -------------------------------------------------------------------------------- /resources/small-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/small-icon.png -------------------------------------------------------------------------------- /resources/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/resources/splash.png -------------------------------------------------------------------------------- /resources/splash.png.md5: -------------------------------------------------------------------------------- 1 | a7ca94922f62339d26e9f75cab84cf73 -------------------------------------------------------------------------------- /src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { PreloadAllModules, RouterModule, Routes } from '@angular/router'; 3 | 4 | const routes: Routes = [ 5 | { path: '', loadChildren: './tabs/tabs.module#TabsPageModule' }, 6 | { path: 'home', loadChildren: './tab1/tab1.module#Tab1PageModule' }, 7 | { path: 'survey/:task_id', loadChildren: './survey/survey.module#SurveyPageModule' } 8 | ]; 9 | @NgModule({ 10 | imports: [ 11 | RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules }) 12 | ], 13 | exports: [RouterModule] 14 | }) 15 | export class AppRoutingModule {} 16 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; 2 | import { TestBed, async } from '@angular/core/testing'; 3 | 4 | import { Platform } from '@ionic/angular'; 5 | import { SplashScreen } from '@ionic-native/splash-screen/ngx'; 6 | import { StatusBar } from '@ionic-native/status-bar/ngx'; 7 | 8 | import { AppComponent } from './app.component'; 9 | 10 | describe('AppComponent', () => { 11 | 12 | let statusBarSpy, splashScreenSpy, platformReadySpy, platformSpy; 13 | 14 | beforeEach(async(() => { 15 | statusBarSpy = jasmine.createSpyObj('StatusBar', ['styleDefault']); 16 | splashScreenSpy = jasmine.createSpyObj('SplashScreen', ['hide']); 17 | platformReadySpy = Promise.resolve(); 18 | platformSpy = jasmine.createSpyObj('Platform', { ready: platformReadySpy }); 19 | 20 | TestBed.configureTestingModule({ 21 | declarations: [AppComponent], 22 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 23 | providers: [ 24 | { provide: StatusBar, useValue: statusBarSpy }, 25 | { provide: SplashScreen, useValue: splashScreenSpy }, 26 | { provide: Platform, useValue: platformSpy }, 27 | ], 28 | }).compileComponents(); 29 | })); 30 | 31 | it('should create the app', () => { 32 | const fixture = TestBed.createComponent(AppComponent); 33 | const app = fixture.debugElement.componentInstance; 34 | expect(app).toBeTruthy(); 35 | }); 36 | 37 | it('should initialize the app', async () => { 38 | TestBed.createComponent(AppComponent); 39 | expect(platformSpy.ready).toHaveBeenCalled(); 40 | await platformReadySpy; 41 | expect(statusBarSpy.styleDefault).toHaveBeenCalled(); 42 | expect(splashScreenSpy.hide).toHaveBeenCalled(); 43 | }); 44 | 45 | // TODO: add more tests! 46 | 47 | }); 48 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, NgZone } from '@angular/core'; 2 | import { LocalNotifications } from '@ionic-native/local-notifications/ngx'; 3 | import { Platform } from '@ionic/angular'; 4 | import { SplashScreen } from '@ionic-native/splash-screen/ngx'; 5 | import { StatusBar } from '@ionic-native/status-bar/ngx'; 6 | import { Router } from '@angular/router'; 7 | import { SurveyDataService } from '../app/services/survey-data.service'; 8 | import * as moment from 'moment'; 9 | import { AlertController } from '@ionic/angular'; 10 | 11 | 12 | @Component({ 13 | selector: 'app-root', 14 | templateUrl: 'app.component.html' 15 | }) 16 | export class AppComponent implements OnInit { 17 | private readyApp!: () => void; 18 | private isAppInForeground: Promise = Promise.resolve(); 19 | 20 | constructor( 21 | private platform: Platform, 22 | private splashScreen: SplashScreen, 23 | private statusBar: StatusBar, 24 | private localNotifications : LocalNotifications, 25 | private surveyDataService: SurveyDataService, 26 | private router : Router, 27 | private ngZone : NgZone, 28 | private alertCtrl: AlertController 29 | ) { 30 | 31 | this.initializeApp(); 32 | } 33 | 34 | async ngOnInit() { 35 | await this.platform.ready(); 36 | 37 | this.platform.pause.subscribe(() => { 38 | this.isAppInForeground = new Promise(resolve => { this.readyApp = resolve }); 39 | }); 40 | 41 | this.platform.resume.subscribe(() => { 42 | this.readyApp(); 43 | }); 44 | 45 | // handle notification click 46 | this.localNotifications.on("click").subscribe(async (notification) => { 47 | await this.isAppInForeground; 48 | // log that the user clicked on this notification 49 | let logEvent = { 50 | timestamp: moment().format(), 51 | milliseconds: moment().valueOf(), 52 | page: 'notification-' + moment(notification.data.task_time).format(), 53 | event: 'click', 54 | module_index: notification.data.task_index 55 | }; 56 | this.surveyDataService.logPageVisitToServer(logEvent); 57 | this.router.navigate(['survey/' + notification.data.task_id]); 58 | }); 59 | // wait for device ready and then fire any pending click events 60 | await this.isAppInForeground; 61 | this.localNotifications.fireQueuedEvents(); 62 | } 63 | 64 | initializeApp() { 65 | this.platform.ready().then(() => { 66 | this.statusBar.styleDefault(); 67 | this.splashScreen.hide(); 68 | }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { BrowserModule } from '@angular/platform-browser'; 3 | import { RouteReuseStrategy } from '@angular/router'; 4 | 5 | import { IonicModule, IonicRouteStrategy } from '@ionic/angular'; 6 | import { SplashScreen } from '@ionic-native/splash-screen/ngx'; 7 | import { StatusBar } from '@ionic-native/status-bar/ngx'; 8 | 9 | import { AppRoutingModule } from './app-routing.module'; 10 | import { AppComponent } from './app.component'; 11 | import { ChartsModule } from 'ng2-charts'; 12 | 13 | import { FormsModule } from '@angular/forms'; 14 | 15 | /* plugins */ 16 | import { BarcodeScanner } from '@ionic-native/barcode-scanner/ngx'; 17 | import { LocalNotifications } from '@ionic-native/local-notifications/ngx'; 18 | import { HttpModule } from '@angular/http'; 19 | import { IonicStorageModule } from '@ionic/storage'; 20 | import { HTTP } from '@ionic-native/http/ngx'; 21 | import { FileTransfer } from '@ionic-native/file-transfer/ngx'; 22 | import { File } from '@ionic-native/file/ngx'; 23 | import { InAppBrowser } from '@ionic-native/in-app-browser/ngx'; 24 | import { HttpClientModule, HttpClient } from '@angular/common/http'; 25 | import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; 26 | import { TranslateHttpLoader } from '@ngx-translate/http-loader'; 27 | 28 | export function LanguageLoader(http: HttpClient) { 29 | return new TranslateHttpLoader(http, 'assets/i18n/', '.json'); 30 | } 31 | 32 | @NgModule({ 33 | declarations: [AppComponent], 34 | entryComponents: [], 35 | imports: [ 36 | BrowserModule, 37 | HttpModule, 38 | ChartsModule, 39 | IonicModule.forRoot(), 40 | IonicStorageModule.forRoot(), 41 | AppRoutingModule, 42 | HttpClientModule, 43 | TranslateModule.forRoot({ 44 | loader: { 45 | provide: TranslateLoader, 46 | useFactory: (LanguageLoader), 47 | deps: [HttpClient] 48 | } 49 | })], 50 | providers: [ 51 | StatusBar, 52 | SplashScreen, 53 | BarcodeScanner, 54 | LocalNotifications, 55 | InAppBrowser, 56 | File, 57 | FileTransfer, 58 | HTTP, 59 | FormsModule, 60 | { provide: RouteReuseStrategy, useClass: IonicRouteStrategy } 61 | ], 62 | bootstrap: [AppComponent] 63 | }) 64 | export class AppModule {} 65 | -------------------------------------------------------------------------------- /src/app/services/loading-service.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { LoadingService } from './loading-service.service'; 4 | 5 | describe('LoadingService', () => { 6 | beforeEach(() => TestBed.configureTestingModule({})); 7 | 8 | it('should be created', () => { 9 | const service: LoadingService = TestBed.get(LoadingService); 10 | expect(service).toBeTruthy(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/app/services/loading-service.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { LoadingController } from '@ionic/angular'; 3 | 4 | @Injectable({ 5 | providedIn: 'root' 6 | }) 7 | export class LoadingService { 8 | 9 | isLoading:boolean = false; 10 | isCaching:boolean = false; 11 | 12 | constructor(public loadingController: LoadingController) { } 13 | 14 | /** 15 | * Displays the loading dialog 16 | * @param msg The message to display in the loading dialog 17 | */ 18 | async present(msg) { 19 | this.isLoading = true 20 | return await this.loadingController.create({ 21 | message: msg, 22 | spinner: "crescent", 23 | duration: 7000 24 | }).then(a => { 25 | a.present().then(() => { 26 | if (!this.isLoading) { 27 | a.dismiss().then(() => console.log('abort presenting')) 28 | } 29 | }) 30 | }) 31 | } 32 | 33 | /** 34 | * Dismisses the loading dialog 35 | */ 36 | async dismiss() { 37 | this.isLoading = false 38 | this.isCaching = false 39 | const loader = await this.loadingController.getTop() 40 | return await loader.dismiss() 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/app/services/notifications.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { NotificationsService } from './notifications.service'; 4 | 5 | describe('NotificationsService', () => { 6 | beforeEach(() => TestBed.configureTestingModule({})); 7 | 8 | it('should be created', () => { 9 | const service: NotificationsService = TestBed.get(NotificationsService); 10 | expect(service).toBeTruthy(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/app/services/notifications.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Storage } from '@ionic/storage'; 3 | import { LocalNotifications } from '@ionic-native/local-notifications/ngx'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class NotificationsService { 9 | 10 | constructor(private localNotifications: LocalNotifications, 11 | private storage: Storage) { } 12 | 13 | /** 14 | * Schedules a notification, taking parameters from a task 15 | * @param task The task that the notification is for 16 | */ 17 | scheduleDummyNotification() { 18 | this.localNotifications.schedule({ 19 | title: "Hello", 20 | text: "World", 21 | foreground: true, 22 | trigger: {at: new Date(new Date().getTime() + 10000)}, 23 | smallIcon: 'res://notification_icon', 24 | icon: 'res//notification_icon', 25 | data: { task_index: 0 }, 26 | launch: true, 27 | wakeup: true, 28 | priority: 2 29 | }) 30 | } 31 | 32 | /** 33 | * Schedules a notification, takoing parameters from a task 34 | * @param task The task that the notification is for 35 | */ 36 | scheduleNotification(task) { 37 | this.localNotifications.schedule({ 38 | id: task.task_id, 39 | title: task.alert_title, 40 | text: task.alert_message, 41 | foreground: true, 42 | trigger: { at: new Date(Date.parse(task.time)) }, 43 | smallIcon: 'res://notification_icon', 44 | icon: 'res//notification_icon', 45 | data: { task_index: task.index, task_id: task.task_id, task_time: task.time }, 46 | launch: true, 47 | wakeup: true, 48 | priority: 2 49 | }) 50 | } 51 | 52 | /** 53 | * Cancels all notifications that have been set 54 | */ 55 | cancelAllNotifications() { 56 | this.localNotifications.cancelAll() 57 | } 58 | 59 | /** 60 | * Sets the next 30 notifications based on the next 30 tasks 61 | */ 62 | async setNext30Notifications() { 63 | await this.localNotifications.cancelAll() 64 | 65 | const notificationsEnabled = await this.storage.get('notifications-enabled') 66 | 67 | if (notificationsEnabled) { 68 | const tasks = await this.storage.get('study-tasks') 69 | if (tasks !== null) { 70 | var alertCount = 0 71 | for (var i = 0; i < tasks.length; i++) { 72 | var task = tasks[i] 73 | var alertTime = new Date(Date.parse(task.time)) 74 | 75 | if (alertTime > new Date()) { 76 | if (this.checkTaskIsUnlocked(task, tasks)) { 77 | this.scheduleNotification(task) 78 | alertCount++ 79 | } 80 | } 81 | 82 | // only set 30 alerts into the future 83 | if (alertCount === 30) break 84 | } 85 | } 86 | } 87 | 88 | /*this.localNotifications.cancelAll().then(() => { 89 | this.storage.get('notifications-enabled').then(notificationsEnabled => { 90 | if (notificationsEnabled) { 91 | this.storage.get('study-tasks').then((tasks) => { 92 | if (tasks !== null) { 93 | var alertCount = 0; 94 | for (var i = 0; i < tasks.length; i++) { 95 | var task = tasks[i]; 96 | var alertTime = new Date(Date.parse(task.time)); 97 | 98 | if (alertTime > new Date()) { 99 | if (this.checkTaskIsUnlocked(task, tasks)) { 100 | this.scheduleNotification(task); 101 | alertCount++; 102 | } 103 | } 104 | 105 | // only set 30 alerts into the future 106 | if (alertCount === 30) break; 107 | } 108 | } 109 | }); 110 | } 111 | }); 112 | });*/ 113 | } 114 | 115 | /** 116 | * 117 | * @param task 118 | * @param study_tasks 119 | */ 120 | checkTaskIsUnlocked(task, study_tasks) { 121 | 122 | // get a set of completed task uuids 123 | let completedUUIDs = new Set() 124 | for (let i = 0; i < study_tasks.length; i++) { 125 | if (study_tasks[i].completed) { 126 | completedUUIDs.add(study_tasks[i].uuid); 127 | } 128 | } 129 | 130 | // get the list of prereqs from the task 131 | let prereqs = task.unlock_after 132 | let unlock = true 133 | for (let i = 0; i < prereqs.length; i++) { 134 | if (!completedUUIDs.has(prereqs[i])) { 135 | unlock = false 136 | break 137 | } 138 | } 139 | 140 | return unlock 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/app/services/study-tasks.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { StudyTasksService } from './study-tasks.service'; 4 | 5 | describe('StudyTasksService', () => { 6 | beforeEach(() => TestBed.configureTestingModule({})); 7 | 8 | it('should be created', () => { 9 | const service: StudyTasksService = TestBed.get(StudyTasksService); 10 | expect(service).toBeTruthy(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/app/services/study-tasks.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Storage } from '@ionic/storage'; 3 | 4 | @Injectable({ 5 | providedIn: 'root' 6 | }) 7 | export class StudyTasksService { 8 | 9 | constructor(private storage: Storage) { } 10 | 11 | /** 12 | * Creates a list of tasks (e.g. surveys, interventions) based on their 13 | * alert schedules 14 | * @param studyObject A JSON object that contains all data about a study 15 | */ 16 | generateStudyTasks(studyObject) { 17 | 18 | interface Task { 19 | uuid:string, 20 | index: number, 21 | task_id: number, 22 | name: string, 23 | type: string, 24 | hidden: boolean, 25 | unlock_after: Array, 26 | sticky: boolean, 27 | sticky_label: string, 28 | alert_title: string, 29 | alert_message: string, 30 | timeout: boolean, 31 | timeout_after: number, 32 | time: string, 33 | locale: string 34 | } 35 | 36 | // allocate the participant to a study condition 37 | const min:number = 1 38 | const max:number = studyObject.properties.conditions.length 39 | const condition_index:number = (Math.floor(Math.random() * (max - min + 1)) + min) - 1 40 | const condition:string = studyObject.properties.conditions[condition_index] 41 | 42 | const study_tasks:Array = new Array() 43 | 44 | // the ID for a task 45 | let task_ID:number = 101; 46 | 47 | // loop through all of the modules in this study 48 | // and create the associated study tasks based 49 | // on the alert schedule 50 | for (let i = 0; i < studyObject.modules.length; i++) { 51 | 52 | let mod = studyObject.modules[i]; 53 | 54 | // if the module is assigned to the participant's condition 55 | // add it to the list, otherwise just skip it 56 | if (mod.condition === condition || mod.condition === "*") { 57 | const module_uuid = mod.uuid === undefined ? -1 : mod.uuid 58 | const module_duration = mod.alerts.duration 59 | const module_offset = mod.alerts.start_offset 60 | const module_unlock_after = mod.unlock_after === undefined ? [] : mod.unlock_after 61 | const module_random = mod.alerts.random 62 | const module_sticky = mod.alerts.sticky 63 | const module_sticky_label = mod.alerts.sticky_label 64 | const module_timeout = mod.alerts.timeout 65 | const module_timeout_after = mod.alerts.timeout_after 66 | const module_randomInterval = mod.alerts.random_interval 67 | const module_times = mod.alerts.times 68 | const alert_title = mod.alerts.title 69 | const alert_message = mod.alerts.message 70 | let module_type = "default" 71 | if (mod.type === "survey") module_type = "checkmark-circle-outline" 72 | if (mod.type === "video") module_type = "film-outline" 73 | if (mod.type === "audio") module_type = "headset-outline" 74 | if (mod.type === "info") module_type = "bulb-outline" 75 | 76 | const module_name = studyObject.modules[i].name 77 | const module_index = i 78 | 79 | const startDay = new Date() // set a date object for today 80 | startDay.setHours(0, 0, 0, 0) // set the time to midnight 81 | 82 | // add offset days to get first day of alerts 83 | startDay.setDate(startDay.getDate() + module_offset) 84 | 85 | // counter to be used when scheduling sticky tasks with notifications 86 | let sticky_count = 0 87 | 88 | for (let numDays = 0; numDays < module_duration; numDays++) { 89 | // for each alert time, get the hour and minutes and if necessary randomise it 90 | for (let t = 0; t < module_times.length; t++) { 91 | const hours = module_times[t].hours 92 | const mins = module_times[t].minutes 93 | 94 | const taskTime = new Date(startDay.getTime()) 95 | taskTime.setHours(hours) 96 | taskTime.setMinutes(mins) 97 | 98 | if (module_random) { 99 | // remove the randomInterval from the time 100 | taskTime.setMinutes(taskTime.getMinutes() - module_randomInterval) 101 | 102 | // calc a random number between 0 and (randomInterval * 2) 103 | // to account for randomInterval either side 104 | const randomMinutes = Math.random() * ((module_randomInterval * 2) - 0) + 0 105 | 106 | // add the random number of minutes to the dateTime 107 | taskTime.setMinutes(taskTime.getMinutes() + randomMinutes) 108 | } 109 | 110 | // create a task object 111 | const options = { weekday: 'short', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' } as const 112 | const task_obj:Task = { 113 | uuid: module_uuid, 114 | index: module_index, 115 | task_id: task_ID, 116 | name: module_name, 117 | type: module_type, 118 | hidden: (module_sticky && sticky_count === 0) ? false : true, 119 | unlock_after: module_unlock_after, 120 | sticky: module_sticky, 121 | sticky_label: module_sticky_label, 122 | alert_title: alert_title, 123 | alert_message: alert_message, 124 | timeout: module_timeout, 125 | timeout_after: module_timeout_after, 126 | time: taskTime.toString(), 127 | locale: taskTime.toLocaleString("en-US", options) 128 | } 129 | 130 | study_tasks.push(task_obj) 131 | 132 | // increment task id 133 | task_ID++ 134 | 135 | // increment the sticky count 136 | sticky_count++ 137 | } 138 | 139 | // as a final step increment the date by 1 to set for next day 140 | startDay.setDate(startDay.getDate() + 1) 141 | } 142 | } 143 | } 144 | 145 | study_tasks.sort(function (a:Task, b:Task) { 146 | let dateA = new Date(a.time) 147 | let dateB = new Date(b.time) 148 | 149 | return dateA.getTime() - dateB.getTime() 150 | }); 151 | 152 | // save tasks and condition to storage 153 | this.storage.set("condition", condition) 154 | this.storage.set("study-tasks", study_tasks) 155 | 156 | return study_tasks 157 | 158 | } 159 | 160 | /** 161 | * Returns all the tasks that have been created for a study 162 | */ 163 | getAllTasks() { 164 | return this.storage.get('study-tasks').then((tasks) => { 165 | return tasks 166 | }); 167 | } 168 | 169 | /** 170 | * Gets the tasks that are currently available for the user to complete 171 | */ 172 | getTaskDisplayList() { 173 | return this.storage.get('study-tasks').then((val) => { 174 | const study_tasks = val 175 | 176 | let tasks_to_display = [] 177 | let sticky_tasks = [] 178 | let time_tasks = [] 179 | 180 | let last_header = "" 181 | 182 | for (let i = 0; i < study_tasks.length; i++) { 183 | const task = study_tasks[i] 184 | // check if task has a pre_req 185 | const unlocked = this.checkTaskIsUnlocked(task, study_tasks) 186 | const alertTime = new Date(Date.parse(task.time)) 187 | const now = new Date() 188 | 189 | if (now > alertTime && unlocked) { 190 | if (task.sticky) { 191 | if (!task.hidden) { 192 | if (last_header != task.sticky_label) { 193 | // push a new header into the sticky_tasks array 194 | let header = { type: 'header', label: task.sticky_label } 195 | sticky_tasks.push(header) 196 | last_header = task.sticky_label 197 | } 198 | // push the sticky task 199 | sticky_tasks.push(task) 200 | } 201 | } else { 202 | // check if task is set to timeout 203 | if (task.timeout) { 204 | let timeoutTime = new Date(Date.parse(task.time)) 205 | timeoutTime = new Date(timeoutTime.getTime() + task.timeout_after) 206 | 207 | if (now < timeoutTime && !task.completed) { 208 | time_tasks.push(task) 209 | } 210 | } 211 | else if (!task.completed) { 212 | time_tasks.push(task) 213 | } 214 | } 215 | } 216 | } 217 | 218 | // reverse the time_tasks list so newest is displayed first 219 | if (time_tasks.length > 0) { 220 | time_tasks.reverse() 221 | const header = { type: 'header', label: "Recent" } 222 | time_tasks.unshift(header) 223 | } 224 | // merge the time_tasks array with the sticky_tasks array 225 | tasks_to_display = time_tasks.concat(sticky_tasks) 226 | // return the tasks list reversed to ensure correct order 227 | return tasks_to_display.reverse() 228 | }); 229 | } 230 | 231 | /** 232 | * 233 | * @param task 234 | * @param study_tasks 235 | */ 236 | checkTaskIsUnlocked(task, study_tasks) { 237 | 238 | // get a set of completed task uuids 239 | let completedUUIDs = new Set(); 240 | for (let i = 0; i < study_tasks.length; i++) { 241 | if (study_tasks[i].completed) { 242 | completedUUIDs.add(study_tasks[i].uuid); 243 | } 244 | } 245 | 246 | // get the list of prereqs from the task 247 | const prereqs = task.unlock_after 248 | let unlock = true 249 | for (let i = 0; i < prereqs.length; i++) { 250 | if (!completedUUIDs.has(prereqs[i])) { 251 | unlock = false 252 | break 253 | } 254 | } 255 | 256 | return unlock 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /src/app/services/survey-cache.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { SurveyCacheService } from './survey-cache.service'; 4 | 5 | describe('FileDownloaderService', () => { 6 | beforeEach(() => TestBed.configureTestingModule({})); 7 | 8 | it('should be created', () => { 9 | const service: SurveyCacheService = TestBed.get(SurveyCacheService); 10 | expect(service).toBeTruthy(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/app/services/survey-cache.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { FileTransfer, FileTransferObject } from '@ionic-native/file-transfer/ngx'; 3 | import { LoadingService } from '../services/loading-service.service'; 4 | import { File } from '@ionic-native/file/ngx'; 5 | import { Storage } from '@ionic/storage'; 6 | 7 | @Injectable({ 8 | providedIn: 'root' 9 | }) 10 | export class SurveyCacheService { 11 | 12 | win: any = window 13 | 14 | mediaToCache:object = {} 15 | videoThumbnailsToCache:object = {} 16 | localMediaURLs:object = {} 17 | localThumbnailURLs:object = {} 18 | mediaCount:number = 0 19 | mediaDownloadedCount:number = 0 20 | 21 | constructor(private fileTransfer: FileTransfer, 22 | private file: File, 23 | private storage: Storage, 24 | private loadingService: LoadingService) { } 25 | 26 | 27 | /** 28 | * Downloads a remote file and converts it to a local URL 29 | * @param url Remote URL to a media file 30 | */ 31 | downloadFile(url) { 32 | const transfer: FileTransferObject = this.fileTransfer.create() 33 | 34 | // get the fileName from the URL 35 | const urlSplit = url.split("/") 36 | const fileName = urlSplit[urlSplit.length - 1] 37 | 38 | return transfer.download(url, this.file.dataDirectory + fileName).then((entry) => { 39 | return entry.toURL() 40 | }, (error) => { 41 | return "" 42 | }) 43 | } 44 | 45 | /** 46 | * Gets all of the remote URLs from the media elements in this study 47 | * @param study The study protocol 48 | */ 49 | getMediaURLs(study) { 50 | // get banner url 51 | this.mediaToCache["banner"] = study.properties.banner_url 52 | 53 | // get urls from media elements 54 | for (const module of study.modules) { 55 | for (const section of module.sections) { 56 | const mediaQuestions = section.questions.filter(question => question.type === "media") 57 | for (const question of mediaQuestions) this.mediaToCache[question.id] = question.src 58 | } 59 | } 60 | // set mediaCount to be number of media items 61 | this.mediaCount = Object.keys(this.mediaToCache).length 62 | } 63 | 64 | /** 65 | * Gets all of the media URLs from the study protocol and downloads the files 66 | * @param study The study protocol 67 | */ 68 | cacheAllMedia(study) { 69 | this.mediaCount = 0 70 | this.mediaDownloadedCount = 0 71 | // map media question ids to their urls 72 | this.getMediaURLs(study) 73 | this.downloadAllMedia() 74 | } 75 | 76 | /** 77 | * Downloads all of the media items from the remote URLs 78 | */ 79 | downloadAllMedia() { 80 | // download all media items 81 | const keys = Object.keys(this.mediaToCache) 82 | for (let i = 0; i < keys.length; i++) { 83 | this.downloadFile(this.mediaToCache[keys[i]]).then(entryURL => { 84 | this.localMediaURLs[keys[i]] = this.win.Ionic.WebView.convertFileSrc(entryURL) 85 | this.mediaDownloadedCount = this.mediaDownloadedCount + 1 86 | this.checkIfFinished() 87 | }); 88 | } 89 | } 90 | 91 | /** 92 | * Checks if all of the media has been downloaded, if so update the protocol 93 | */ 94 | checkIfFinished() { 95 | if (this.mediaDownloadedCount === this.mediaCount) 96 | this.updateMediaURLsInStudy() 97 | } 98 | 99 | /** 100 | * Replaces the remote URLs for media items with the local URLs 101 | */ 102 | updateMediaURLsInStudy() { 103 | this.storage.get('current-study').then((studyString) => { 104 | try { 105 | const studyObject = JSON.parse(studyString) 106 | // update the banner url first 107 | studyObject.properties.banner_url = this.localMediaURLs["banner"] 108 | 109 | // update the other media items to the corresponding local URL 110 | // get urls from media elements 111 | for (const module of studyObject.modules) 112 | for (const section of module) 113 | for (const question of section) { 114 | if (question.id in this.localMediaURLs) question.src = this.localMediaURLs[question.id] 115 | if (question.subtype === "video") question.thumb = this.localMediaURLs["banner"] 116 | } 117 | 118 | // update the study protocol in storage 119 | this.storage.set('current-study', JSON.stringify(studyObject)) 120 | } catch (e) { 121 | console.log("error: " + e) 122 | } 123 | 124 | // dismiss the loading spinner 125 | this.loadingService.dismiss() 126 | }) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/app/services/survey-data.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { SurveyDataService } from './survey-data.service'; 4 | 5 | describe('SurveyDataService', () => { 6 | beforeEach(() => TestBed.configureTestingModule({})); 7 | 8 | it('should be created', () => { 9 | const service: SurveyDataService = TestBed.get(SurveyDataService); 10 | expect(service).toBeTruthy(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/app/services/survey-data.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Storage } from '@ionic/storage'; 3 | import { Platform } from '@ionic/angular'; 4 | import { StudyTasksService } from '../services/study-tasks.service'; 5 | import { UuidService } from '../services/uuid.service'; 6 | import { Http } from '@angular/http'; 7 | import { HTTP } from '@ionic-native/http/ngx'; 8 | 9 | @Injectable({ 10 | providedIn: 'root' 11 | }) 12 | export class SurveyDataService { 13 | 14 | constructor(private http: Http, 15 | private http2: HTTP, 16 | private storage: Storage, 17 | private platform: Platform, 18 | private uuidService: UuidService, 19 | private studyTasksService: StudyTasksService) { } 20 | 21 | /** 22 | * Downloads a survey from a remote URL 23 | * @param surveyURL The web URL where a survey is hosted. 24 | */ 25 | getRemoteData(surveyURL: string) { 26 | return new Promise(resolve => { 27 | this.http2.setRequestTimeout(7) 28 | this.http2.post(surveyURL, {seed: 'f2d91e73'}, {}).then(data => { 29 | resolve(data) 30 | }).catch(error => { 31 | resolve(error) 32 | }); 33 | }); 34 | } 35 | 36 | async saveToLocalStorage(key, data) { 37 | this.storage.set(key, data) 38 | } 39 | 40 | /** 41 | * Attempts to submit a survey response to the server, and if unsuccessful saves it for later attempts 42 | * @param surveyData An object containing all metadata about a survey response 43 | */ 44 | sendSurveyDataToServer(surveyData) { 45 | return Promise.all([this.storage.get("current-study"), this.storage.get("uuid"), this.studyTasksService.getAllTasks()]).then((values) => { 46 | const studyJSON = JSON.parse(values[0]) 47 | const uuid = values[1] 48 | const tasks = values[2] 49 | const dataUuid = this.uuidService.generateUUID("pending-data") 50 | 51 | // create form data to store the survey data 52 | const bodyData = new FormData() 53 | bodyData.append("data_type", "survey_response") 54 | bodyData.append("user_id", uuid) 55 | bodyData.append("study_id", studyJSON.properties.study_id) 56 | bodyData.append("module_index", surveyData.module_index) 57 | bodyData.append("module_name", surveyData.module_name) 58 | bodyData.append("responses", JSON.stringify(surveyData.responses)) 59 | bodyData.append("response_time", surveyData.response_time) 60 | bodyData.append("response_time_in_ms", surveyData.response_time_in_ms) 61 | bodyData.append("alert_time", surveyData.alert_time) 62 | bodyData.append("platform", this.platform.platforms()[0]) 63 | 64 | return this.attemptHttpPost(studyJSON.properties.post_url, bodyData).then((postSuccessful) => { 65 | if (!postSuccessful) { 66 | var object = {} 67 | bodyData.forEach(function(value, key){ 68 | object[key] = value 69 | }); 70 | var json = JSON.stringify(object) 71 | this.storage.set(dataUuid, json) 72 | } 73 | }); 74 | }); 75 | } 76 | 77 | /** 78 | * Attempts to send a log (e.g. page visit) to the server, and if unsuccessful saves it for later attempts 79 | * @param logEvent An object containing metadata about a log event 80 | */ 81 | logPageVisitToServer(logEvent) { 82 | return Promise.all([this.storage.get("current-study"), this.storage.get("uuid")]).then(values => { 83 | const studyJSON = JSON.parse(values[0]) 84 | const uuid = values[1] 85 | const logUuid = this.uuidService.generateUUID("pending-log") 86 | 87 | // create form data to store the log data 88 | const bodyData = new FormData() 89 | bodyData.append("data_type", "log") 90 | bodyData.append("user_id", uuid) 91 | bodyData.append("study_id", studyJSON.properties.study_id) 92 | bodyData.append("module_index", logEvent.module_index) 93 | bodyData.append("page", logEvent.page) 94 | bodyData.append("event", logEvent.event) 95 | bodyData.append("timestamp", logEvent.timestamp) 96 | bodyData.append("timestamp_in_ms", logEvent.milliseconds) 97 | bodyData.append("platform", this.platform.platforms()[0]) 98 | 99 | return this.attemptHttpPost(studyJSON.properties.post_url, bodyData).then((postSuccessful) => { 100 | if (!postSuccessful) { 101 | var object = {} 102 | bodyData.forEach(function(value, key){ 103 | object[key] = value 104 | }); 105 | var json = JSON.stringify(object) 106 | this.storage.set(logUuid, json) 107 | } 108 | }); 109 | }); 110 | } 111 | 112 | /** 113 | * Attempts to upload any logs/data that was unsuccessfully sent to the server on previous attempts 114 | * @param dataType The type of data to attempt to upload, e.g. 'pending-logs' (log events) or 'pending-data' (survey responses) 115 | */ 116 | uploadPendingData(dataType) { 117 | return Promise.all([this.storage.get("current-study"), this.storage.keys()]).then(values => { 118 | const studyJSON = JSON.parse(values[0]) 119 | const keys = values[1]; 120 | 121 | let pendingLogKeys = []; 122 | for (let i = 0; i < keys.length; i++) { 123 | if (keys[i].startsWith(dataType)) { 124 | pendingLogKeys.push(keys[i]) 125 | } 126 | } 127 | return { 128 | pendingLogKeys: pendingLogKeys, 129 | post_url: studyJSON.properties.post_url 130 | } 131 | }).then((data) => { 132 | data.pendingLogKeys.map(pendingKey => { 133 | this.storage.get(pendingKey).then((log) => { 134 | const logJSONObj = JSON.parse(log) 135 | const bodyData = new FormData() 136 | for (var key in logJSONObj) { 137 | if (logJSONObj.hasOwnProperty(key)) { 138 | bodyData.append(key, logJSONObj[key]) 139 | } 140 | } 141 | this.attemptHttpPost(data.post_url, bodyData).then((postSuccessful) => { 142 | if (postSuccessful) { 143 | this.storage.remove(pendingKey) 144 | } 145 | }); 146 | }); 147 | }); 148 | }); 149 | } 150 | 151 | /** 152 | * Attempts to send the survey data via POST to a server 153 | * @param postURL The URL for a study's data collection server 154 | * @param bodyData The data to send to that server 155 | */ 156 | attemptHttpPost(postURL, bodyData) { 157 | return new Promise(resolve => { 158 | this.http 159 | .post(postURL, bodyData) 160 | .subscribe( 161 | data => { 162 | if (data.status === 200) { 163 | resolve(true) 164 | } else { 165 | resolve(false) 166 | } 167 | }, 168 | err => { 169 | resolve(false) 170 | } 171 | ); 172 | }); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/app/services/uuid.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { UuidService } from './uuid.service'; 4 | 5 | describe('UuidService', () => { 6 | beforeEach(() => TestBed.configureTestingModule({})); 7 | 8 | it('should be created', () => { 9 | const service: UuidService = TestBed.get(UuidService); 10 | expect(service).toBeTruthy(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/app/services/uuid.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | @Injectable({ 4 | providedIn: 'root' 5 | }) 6 | export class UuidService { 7 | 8 | constructor() { } 9 | 10 | // implementation taken from https://www.fiznool.com/blog/2014/11/16/short-id-generation-in-javascript/ 11 | generateUUID(prefix) { 12 | const ALPHABET = '23456789ABDEGJKMNPQRVWXYZ' 13 | const ID_LENGTH = 8 14 | 15 | let rtn = '' 16 | for (let i = 0; i < ID_LENGTH; i++) { 17 | rtn += ALPHABET.charAt(Math.floor(Math.random() * ALPHABET.length)) 18 | } 19 | return prefix + rtn 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/app/survey/survey.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { Routes, RouterModule } from '@angular/router'; 5 | 6 | import { IonicModule } from '@ionic/angular'; 7 | 8 | import { SurveyPage } from './survey.page'; 9 | 10 | const routes: Routes = [ 11 | { 12 | path: '', 13 | component: SurveyPage 14 | } 15 | ]; 16 | 17 | @NgModule({ 18 | imports: [ 19 | CommonModule, 20 | FormsModule, 21 | IonicModule, 22 | RouterModule.forChild(routes) 23 | ], 24 | declarations: [SurveyPage] 25 | }) 26 | export class SurveyPageModule {} 27 | -------------------------------------------------------------------------------- /src/app/survey/survey.page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | schema 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {{this.current_section_name}} 17 | 18 |
19 | {{this.current_section}}/{{this.num_sections}} 20 |
21 |
22 |
23 | 24 | 25 | 26 |
27 | 28 | 30 | 31 | 32 |
33 |

34 |
35 | 36 | 37 |
38 |

39 | 40 | 42 | 43 |

{{question.hint_left}}

44 |

{{question.hint_right}}

45 |
46 | 47 | 48 |
49 |

50 | 51 | 54 | 55 | 56 | 59 | 60 | 61 | 62 | 63 |
64 | 65 | 66 |
67 |

68 | 69 | 71 | 72 | 73 | 75 | 76 | 78 |
79 | 80 | 81 |
82 |

83 | 84 |
85 | 86 | 87 | 88 | {{option.text}} 89 | 90 | 91 | {{option.text}} 92 | 93 | 94 | 95 |
96 | 97 |
98 | 99 | 100 | {{option.text}} 101 | 102 | 103 | 104 | 105 |
106 | 107 | {{option.text}} 108 | 109 | 110 |
111 |
112 | 113 |
114 |
115 | 116 | 117 |
118 |

119 | 120 | 122 | 123 | 125 | 126 | 128 | 129 |
130 | 131 | 132 |
133 |

134 | 135 | 136 | 137 | {{question.yes_text}} 138 | 139 | 140 | {{question.no_text}} 141 | 142 | 143 |
144 |
145 | 146 | 147 |
148 | 149 |
150 | 151 | 152 |
153 |

154 | 155 | 156 | 157 | {{question.file_name}} 158 | 159 |

160 |
161 | 162 |
163 | 164 | 165 | 166 | 167 | 168 | {{this.submit_text}} 169 | 170 | 171 | 172 |
173 | 174 |
-------------------------------------------------------------------------------- /src/app/survey/survey.page.scss: -------------------------------------------------------------------------------- 1 | #section-title { 2 | font-weight:50; 3 | } 4 | 5 | .videoPlayer { 6 | width:100%; 7 | } 8 | 9 | .showError:before { content:" *"; } 10 | 11 | .showError { 12 | font-weight:bold; 13 | color:#F44336 !important; 14 | } 15 | 16 | .scroll-content { 17 | padding-bottom: 0 !important; 18 | } 19 | 20 | p { 21 | white-space: pre-wrap; 22 | } 23 | 24 | .slider-label-left { 25 | width:50%; 26 | text-align:left; 27 | float:left; 28 | font-size:small; 29 | margin-top:-5px; 30 | color:gray; 31 | } 32 | 33 | .slider-label-right { 34 | width:50%; 35 | text-align:right; 36 | float:left; 37 | font-size:small; 38 | margin-top:-5px; 39 | color:gray; 40 | } 41 | 42 | .external-container { 43 | min-height: 100px; 44 | background: url("../../assets/imgs/spinner.gif"); 45 | background-repeat: no-repeat; 46 | background-position: center; 47 | background-size: 50px 50px; 48 | overflow: auto; 49 | -webkit-overflow-scrolling:touch; 50 | } 51 | -------------------------------------------------------------------------------- /src/app/survey/survey.page.spec.ts: -------------------------------------------------------------------------------- 1 | import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; 2 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 3 | 4 | import { SurveyPage } from './survey.page'; 5 | 6 | describe('SurveyPage', () => { 7 | let component: SurveyPage; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(async(() => { 11 | TestBed.configureTestingModule({ 12 | declarations: [ SurveyPage ], 13 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 14 | }) 15 | .compileComponents(); 16 | })); 17 | 18 | beforeEach(() => { 19 | fixture = TestBed.createComponent(SurveyPage); 20 | component = fixture.componentInstance; 21 | fixture.detectChanges(); 22 | }); 23 | 24 | it('should create', () => { 25 | expect(component).toBeTruthy(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/app/survey/survey.page.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ViewChild, NgZone } from '@angular/core'; 2 | import { DomSanitizer } from '@angular/platform-browser'; 3 | import { ActivatedRoute } from '@angular/router'; 4 | import { Storage } from '@ionic/storage'; 5 | import { StatusBar } from '@ionic-native/status-bar/ngx'; 6 | import { StudyTasksService } from '../services/study-tasks.service'; 7 | import { SurveyDataService } from '../services/survey-data.service'; 8 | import { NavController, IonContent, ToastController } from '@ionic/angular'; 9 | import { InAppBrowser } from '@ionic-native/in-app-browser/ngx'; 10 | import * as moment from 'moment'; 11 | 12 | @Component({ 13 | selector: 'app-survey', 14 | templateUrl: './survey.page.html', 15 | styleUrls: ['./survey.page.scss'], 16 | }) 17 | export class SurveyPage implements OnInit { 18 | 19 | @ViewChild(IonContent, {static: false}) content: IonContent 20 | 21 | // the text to display as submit button label 22 | submit_text: string = "Submit" 23 | 24 | // variables to handle the sections 25 | current_section: number = 1 26 | num_sections: number 27 | current_section_name: string 28 | 29 | // study object 30 | study: any 31 | // survey template - load prior to data from storage 32 | survey: any = { 33 | sections: [{ 34 | questions: [], 35 | name: "", 36 | shuffle: false 37 | }], 38 | shuffle: false, 39 | submit_text: "" 40 | }; 41 | questions: any 42 | 43 | // task objects 44 | tasks: Array 45 | task_id: string 46 | task_index: number 47 | module_index: number 48 | module_name: string 49 | 50 | constructor(private route: ActivatedRoute, 51 | private storage: Storage, 52 | private statusBar: StatusBar, 53 | private domSanitizer: DomSanitizer, 54 | private navController: NavController, 55 | private studyTasksService: StudyTasksService, 56 | private surveyDataService: SurveyDataService, 57 | private toastController: ToastController, 58 | private ngZone: NgZone, 59 | private iab: InAppBrowser) { } 60 | 61 | /** 62 | * Triggered when the survey page is first opened 63 | * Initialises the survey and displays it on the screen 64 | */ 65 | ngOnInit() { 66 | // set statusBar to visible on Android 67 | this.statusBar.styleLightContent() 68 | this.statusBar.backgroundColorByHexString('#0F2042') 69 | 70 | // necessary to update height of external embedded content 71 | window.addEventListener('message', function(e) { 72 | if (e.data.hasOwnProperty("frameHeight")) { 73 | (document.querySelector('iframe[src^="'+e.data.url+'"]')).style.height = `${e.data.frameHeight + 10}px`; 74 | (document.querySelector('iframe[src^="'+e.data.url+'"]')).style.width = `99%` 75 | } 76 | }) 77 | 78 | // the id of the task to be displayed 79 | this.task_id = this.route.snapshot.paramMap.get('task_id') 80 | 81 | Promise.all([this.storage.get("current-study"), this.storage.get("uuid")]).then(values => { 82 | 83 | const studyObject = values[0] 84 | const uuid = values [1] 85 | 86 | // get the task object for this task 87 | this.studyTasksService.getAllTasks().then(tasks => { 88 | this.tasks = tasks 89 | for (let i = 0; i < this.tasks.length; i++) { 90 | if (this.task_id == this.tasks[i].task_id) { 91 | this.module_name = this.tasks[i].name 92 | this.module_index = this.tasks[i].index 93 | this.task_index = i 94 | break 95 | } 96 | } 97 | 98 | // check if this task is valid 99 | const availableTasks = this.studyTasksService.getTaskDisplayList().then(tasks => { 100 | let taskAvailable = false; 101 | for (let i = 0; i < tasks.length; i++) { 102 | if (tasks[i].task_id == this.task_id) { 103 | taskAvailable = true 104 | break 105 | } 106 | } 107 | if (!taskAvailable) { 108 | this.showToast("This task had a time limit and is no longer available.", "bottom") 109 | this.navController.navigateRoot('/') 110 | } 111 | }); 112 | 113 | // extract the JSON from the study object 114 | this.study = JSON.parse(studyObject) 115 | 116 | // get the correct module 117 | this.survey = this.study.modules[this.module_index] 118 | 119 | // shuffle modules if required 120 | if (this.survey.shuffle) { 121 | this.survey.sections = this.shuffle(this.survey.sections) 122 | } 123 | 124 | // shuffle questions if required 125 | for (let i = 0; i < this.survey.sections.length; i++) { 126 | if (this.survey.sections[i].shuffle) { 127 | this.survey.sections[i].questions = this.shuffle(this.survey.sections[i].questions) 128 | } 129 | } 130 | 131 | // get the name of the current section 132 | this.num_sections = this.survey.sections.length 133 | this.current_section_name = this.survey.sections[this.current_section - 1].name 134 | 135 | // get the user ID and then set up question variables 136 | // initialise all of the questions to be displayed 137 | this.setupQuestionVariables(uuid) 138 | 139 | // set the submit text as appropriate 140 | if (this.current_section < this.num_sections) { 141 | this.submit_text = "Next" 142 | } else { 143 | this.submit_text = this.survey.submit_text 144 | } 145 | 146 | // set the current section of questions 147 | this.questions = this.survey.sections[this.current_section - 1].questions 148 | 149 | // toggle rand_group questions 150 | // figure out which ones are grouped together, randomly show one and set its response value to 1 151 | const randomGroups = {} 152 | for (let i = 0; i < this.survey.sections.length; i++) { 153 | for (let j = 0; j < this.survey.sections[i].questions.length; j++) { 154 | let question = this.survey.sections[i].questions[j] 155 | if (question.rand_group !== undefined) { 156 | 157 | // set a flag to indicate that this question shouldn't reappear via branching logic 158 | question.noToggle = true 159 | 160 | // categorise questions by rand_group 161 | if (!(question.rand_group in randomGroups)) { 162 | randomGroups[question.rand_group] = [] 163 | randomGroups[question.rand_group].push(question.id) 164 | } else { 165 | randomGroups[question.rand_group].push(question.id) 166 | } 167 | } 168 | } 169 | } 170 | 171 | // from each rand_group, select a random item to show 172 | const showThese = [] 173 | for (let key in randomGroups) { 174 | if (randomGroups.hasOwnProperty(key)) { 175 | // select a random value from each array and add it to the "showThese array" 176 | showThese.push(randomGroups[key][Math.floor(Math.random() * randomGroups[key].length)]) 177 | } 178 | } 179 | 180 | // iterate back through and show the ones that have been randomly calculated 181 | // while removing the branching attributes from those that are hidden 182 | for (let i = 0; i < this.survey.sections.length; i++) { 183 | for (let j = 0; j < this.survey.sections[i].questions.length; j++) { 184 | const question = this.survey.sections[i].questions[j] 185 | if (showThese.includes(question.id)) { 186 | question.noToggle = false 187 | question.response = 1 188 | // hide any questions from the rand_group that were not made visible 189 | // and remove any branching logic attributes 190 | } else if (question.noToggle) { 191 | question.hideSwitch = false 192 | delete question.hide_id 193 | delete question.hide_value 194 | delete question.hide_if 195 | } 196 | } 197 | } 198 | 199 | // toggle dynamic question setup 200 | for (let i = 0; i < this.survey.sections.length; i++) { 201 | for (let j = 0; j < this.survey.sections[i].questions.length; j++) { 202 | this.toggleDynamicQuestions(this.survey.sections[i].questions[j]) 203 | } 204 | } 205 | 206 | // log the user visiting this tab 207 | this.surveyDataService.logPageVisitToServer({ 208 | timestamp: moment().format(), 209 | milliseconds: moment().valueOf(), 210 | page: 'survey', 211 | event: 'entry', 212 | module_index: this.module_index 213 | }); 214 | }); 215 | }); 216 | } 217 | 218 | /** 219 | * Handles the back button behaviour 220 | */ 221 | back() { 222 | if (this.current_section > 1) { 223 | this.ngZone.run(() => { 224 | this.current_section-- 225 | this.current_section_name = this.survey.sections[this.current_section - 1].name 226 | this.questions = this.survey.sections[this.current_section - 1].questions 227 | this.submit_text = "Next" 228 | }) 229 | } else { 230 | // save an exit log 231 | this.surveyDataService.logPageVisitToServer({ 232 | timestamp: moment().format(), 233 | milliseconds: moment().valueOf(), 234 | page: 'survey', 235 | event: 'exit', 236 | module_index: this.module_index 237 | }) 238 | // nav back to the home screen 239 | this.navController.navigateRoot('/') 240 | } 241 | } 242 | 243 | /** 244 | * Sets up any questions that need initialisation before display 245 | * e.g. sets date/time objects to current date/time, set default values for sliders, etc. 246 | */ 247 | setupQuestionVariables(uuid) { 248 | // for all relevant questions add an empty response variable 249 | for (let i = 0; i < this.survey.sections.length; i++) { 250 | for (let j = 0; j < this.survey.sections[i].questions.length; j++) { 251 | 252 | const question = this.survey.sections[i].questions[j] 253 | 254 | // for all question types that can be responded to, set default values 255 | question.response = "" 256 | question.model = "" 257 | question.hideError = true 258 | question.hideSwitch = true 259 | 260 | // for datetime questions, default to the current date/time 261 | if (question.type === "datetime") { 262 | // placeholder for dates 263 | question.model = moment().format() 264 | 265 | // for audio/video questions, sanitize the URLs to make them safe/work in html5 tags 266 | } else if (question.type === "media" && (question.subtype === "audio" || question.subtype === "video")) { 267 | question.src = this.domSanitizer.bypassSecurityTrustResourceUrl(question.src) 268 | if (question.subtype === "video") question.thumb = this.domSanitizer.bypassSecurityTrustResourceUrl(question.thumb) 269 | 270 | // for external embedded content, sanitize the URLs to make them safe/work in html5 tags 271 | } else if (question.type === "external") { 272 | question.src = question.src + "?uuid=" + uuid 273 | question.src = this.domSanitizer.bypassSecurityTrustResourceUrl(question.src) 274 | 275 | // for slider questions, set the default value to be halfway between min and max 276 | } else if (question.type === "slider") { 277 | // get min and max 278 | const min = question.min 279 | const max = question.max 280 | 281 | // set the default value of the slider to the middle value 282 | const model = min + ((max - min) / 2) 283 | question.model = model 284 | 285 | // a starting value must also be set for the slider to work properly 286 | question.value = model 287 | 288 | // for checkbox items, the response is set to an empty array 289 | } else if (question.type === 'multi') { 290 | 291 | // set up checked tracking for checkbox questions types 292 | const tempOptions = [] 293 | for (let i = 0; i < question.options.length; i++) { 294 | tempOptions.push({text: question.options[i], checked: false}) 295 | } 296 | question.options = tempOptions 297 | 298 | // counterbalance the choices if necessary 299 | if (question.shuffle) 300 | question.options = this.shuffle(question.options) 301 | 302 | // set the empty response to an array for checkbox questions 303 | if (question.radio === "false") 304 | question.response = []; 305 | } 306 | } 307 | } 308 | } 309 | 310 | /** 311 | * Saves the response to a question and triggers and branching 312 | * @param question The question that has been answered 313 | */ 314 | setAnswer(question) { 315 | // save the response and hide error 316 | question.response = question.model 317 | question.hideError = true 318 | 319 | // trigger any branching tied to this question 320 | this.toggleDynamicQuestions(question) 321 | 322 | } 323 | 324 | /** 325 | * Fires every time a checkbox question is answered; converts the response(s) to a String 326 | * @param option The option selected in a checkbox group 327 | * @param question The question that has been answered 328 | */ 329 | changeCheckStatus(option, question) { 330 | 331 | // get question responses and split 332 | let responses = [] 333 | 334 | // split all of the responses up into individual strings 335 | if (question.response !== "") { 336 | responses = question.response.toString().split(";") 337 | responses.pop() 338 | } 339 | 340 | // if the checked item was unchecked then remove it 341 | // otherwise add it to the response array 342 | if (responses.indexOf(option.text) > -1) { 343 | // remove it 344 | let index = responses.indexOf(option.text) 345 | if (index !== -1) responses.splice(index, 1) 346 | } else { 347 | responses.push(option.text) 348 | } 349 | 350 | // write the array back to a single string 351 | let response_string = "" 352 | for (let i = 0; i < responses.length; i++) { 353 | response_string += responses[i] + ";" 354 | } 355 | 356 | // hide any non-response error 357 | question.hideError = true 358 | question.response = response_string 359 | } 360 | 361 | /** 362 | * Opens an external file in the in app browser 363 | * @param url The url of the PDF file to open 364 | */ 365 | openExternalFile(url) { 366 | const browser = this.iab.create(url, "_system") 367 | } 368 | 369 | toggleDynamicQuestions(question) { 370 | // if a question was hidden by rand_group 371 | // don't do any branching 372 | if (question.noToggle !== undefined && question.noToggle) 373 | return 374 | 375 | const id = question.id 376 | // hide anything with the id as long as the value is equal 377 | for (let i = 0; i < this.survey.sections.length; i++) { 378 | for (let j = 0; j < this.survey.sections[i].questions.length; j++) { 379 | if (this.survey.sections[i].questions[j].hide_id === id) { 380 | const hideValue = this.survey.sections[i].questions[j].hide_value 381 | 382 | if (question.type === "multi" || question.type === "yesno" || question.type === "text") { 383 | 384 | // determine whether to hide/show the element 385 | const hideIf = this.survey.sections[i].questions[j].hide_if 386 | const valueEquals = (hideValue === question.response) 387 | if (valueEquals === hideIf) 388 | this.survey.sections[i].questions[j].hideSwitch = false 389 | else 390 | this.survey.sections[i].questions[j].hideSwitch = true 391 | } 392 | else if (question.type === "slider") { 393 | const direction = hideValue.substring(0, 1) 394 | const cutoff = parseInt(hideValue.substring(1, hideValue.length)) 395 | let lesserThan = true 396 | if (direction === ">") lesserThan = false 397 | if (lesserThan) { 398 | if (question.response <= cutoff) { 399 | this.questions[i].hideSwitch = true 400 | } else { 401 | this.questions[i].hideSwitch = false 402 | } 403 | } else { 404 | if (question.response >= cutoff) { 405 | this.questions[i].hideSwitch = true 406 | } else { 407 | this.questions[i].hideSwitch = false 408 | } 409 | } 410 | } 411 | } 412 | } 413 | } 414 | } 415 | 416 | /** 417 | * Triggered whenever the submit button is called 418 | * Checks if all required questions have been answered and then moves to the next section/saves the response 419 | */ 420 | submit() { 421 | let errorCount = 0 422 | for (let i = 0; i < this.questions.length; i++) { 423 | const question = this.questions[i] 424 | if (question.required === true 425 | && (question.response === "" || question.response === undefined) 426 | && question.hideSwitch === true) { 427 | question.hideError = false 428 | errorCount++ 429 | } else { 430 | question.hideError = true 431 | } 432 | } 433 | 434 | if (errorCount == 0) { 435 | 436 | // if user on last page and there are no errors, fine to submit 437 | if (this.current_section === this.num_sections) { 438 | 439 | // add the alert time to the response 440 | this.tasks[this.task_index].alert_time = moment(this.tasks[this.task_index].time).format() 441 | 442 | // get a timestmap of submission time in both readable and ms format 443 | const response_time = moment().format() 444 | this.tasks[this.task_index].response_time = response_time 445 | 446 | const response_time_ms = moment().valueOf() 447 | this.tasks[this.task_index].response_time_ms = response_time_ms 448 | 449 | // indicate that the current task is completed 450 | this.tasks[this.task_index].completed = true 451 | 452 | // add all of the responses to an object in the task to be sent to server 453 | const responses = {} 454 | for (let i = 0; i < this.survey.sections.length; i++) { 455 | for (let j = 0; j < this.survey.sections[i].questions.length; j++) { 456 | const question = this.survey.sections[i].questions[j] 457 | responses[question.id] = question.response 458 | } 459 | } 460 | this.tasks[this.task_index].responses = responses 461 | 462 | // attempt to post surveyResponse to server 463 | this.surveyDataService.sendSurveyDataToServer({ 464 | module_index: this.module_index, 465 | module_name: this.module_name, 466 | responses: responses, 467 | response_time: response_time, 468 | response_time_in_ms: response_time_ms, 469 | alert_time: this.tasks[this.task_index].alert_time 470 | }) 471 | 472 | // write tasks back to storage 473 | this.storage.set("study-tasks", this.tasks).then(() => { 474 | // save an exit log 475 | this.surveyDataService.logPageVisitToServer({ 476 | timestamp: moment().format(), 477 | milliseconds: moment().valueOf(), 478 | page: 'survey', 479 | event: 'submit', 480 | module_index: this.module_index 481 | }) 482 | this.navController.navigateRoot('/') 483 | }) 484 | 485 | } else { 486 | this.ngZone.run(() => { 487 | this.current_section++ 488 | this.questions = this.survey.sections[this.current_section - 1].questions 489 | this.current_section_name = this.survey.sections[this.current_section - 1].name 490 | 491 | if (this.current_section === this.num_sections) 492 | this.submit_text = this.survey.submit_text 493 | 494 | this.content.scrollToTop(0) 495 | }) 496 | } 497 | } else { 498 | this.content.scrollToTop(500) 499 | this.showToast("You must answer all required (*) questions", "bottom") 500 | } 501 | } 502 | 503 | /** 504 | * Creates a Toast object to display a message to the user 505 | * @param message A message to display in the toast 506 | * @param position The position on the screen to display the toast 507 | */ 508 | async showToast(message, position) { 509 | const toast = await this.toastController.create({ 510 | message: message, 511 | position: position, 512 | keyboardClose: true, 513 | color: "danger", 514 | buttons: [ 515 | { 516 | text: 'Dismiss', 517 | role: 'cancel', 518 | handler: () => { 519 | } 520 | } 521 | ] 522 | }) 523 | 524 | toast.present() 525 | } 526 | 527 | /** 528 | * Randomly shuffle an array 529 | * https://stackoverflow.com/a/2450976/1293256 530 | * @param {Array} array The array to shuffle 531 | * @return {String} The first item in the shuffled array 532 | */ 533 | shuffle(array) { 534 | 535 | let currentIndex = array.length 536 | let temporaryValue, randomIndex 537 | 538 | // While there remain elements to shuffle... 539 | while (0 !== currentIndex) { 540 | // Pick a remaining element... 541 | randomIndex = Math.floor(Math.random() * currentIndex) 542 | currentIndex -= 1 543 | 544 | // And swap it with the current element. 545 | temporaryValue = array[currentIndex] 546 | array[currentIndex] = array[randomIndex] 547 | array[randomIndex] = temporaryValue 548 | } 549 | return array 550 | } 551 | } 552 | -------------------------------------------------------------------------------- /src/app/tab1/tab1.module.ts: -------------------------------------------------------------------------------- 1 | import { IonicModule } from '@ionic/angular'; 2 | import { RouterModule } from '@angular/router'; 3 | import { NgModule } from '@angular/core'; 4 | import { CommonModule } from '@angular/common'; 5 | import { FormsModule } from '@angular/forms'; 6 | import { Tab1Page } from './tab1.page'; 7 | import { TranslateModule } from '@ngx-translate/core'; 8 | 9 | @NgModule({ 10 | imports: [ 11 | IonicModule, 12 | CommonModule, 13 | FormsModule, 14 | TranslateModule.forChild(), 15 | RouterModule.forChild([{ path: '', component: Tab1Page }]) 16 | ], 17 | declarations: [Tab1Page] 18 | }) 19 | export class Tab1PageModule {} 20 | -------------------------------------------------------------------------------- /src/app/tab1/tab1.page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | schema 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 | 18 | 19 | 20 |
21 |

Let's get started

22 |

23 | Welcome to schema - a platform to participate in research surveys directly from your smartphone. 24 |

25 |
26 | 27 |
28 | 29 | 30 | 31 |
32 | 33 | 34 | 35 | 36 |
37 | {{task.moment}} 38 |
39 |
40 | 41 | 42 | 43 | {{task.label}} 44 | 45 | 46 |
47 | 48 | 49 | 50 | {{study.properties.empty_msg}} 51 |

52 | 53 |
54 |
55 | 56 |
57 |
58 | 59 | 60 |

61 | To begin, enrol in a study: 62 |

63 |

64 | To begin, enrol in a study: 65 |

66 | 67 | 68 | Scan QR Code 69 | 70 | 71 | 72 | 73 | 74 | 75 | Enter URL 76 | 77 | 78 | 79 | 80 | Study ID 81 | 82 | 83 | 84 |
85 | -------------------------------------------------------------------------------- /src/app/tab1/tab1.page.scss: -------------------------------------------------------------------------------- 1 | ion-content { 2 | --offset-top: 1px; 3 | } 4 | 5 | .page-home { 6 | .moment { 7 | font-size: small; 8 | color: gray; 9 | } 10 | 11 | .rotate-90 { 12 | display: inline-block; 13 | transform: rotate(90deg); 14 | } 15 | } 16 | 17 | .secondary { 18 | color: #04998b; 19 | } 20 | 21 | .welcome-msg { 22 | text-align: center; 23 | width: 80%; 24 | margin-left: auto; 25 | margin-right: auto; 26 | } 27 | 28 | .enrolment-img-dark { 29 | height: 40%; 30 | margin-top: 10%; 31 | margin-left: auto; 32 | margin-right: auto; 33 | } 34 | 35 | .enrolment-img-light { 36 | height: 35%; 37 | margin-top: 10%; 38 | margin-left: auto; 39 | margin-right: auto; 40 | } 41 | 42 | .enrolment-text { 43 | text-align: center; 44 | width: 80%; 45 | margin-left: auto; 46 | margin-right: auto; 47 | margin-bottom: 5%; 48 | font-weight: bold; 49 | } 50 | -------------------------------------------------------------------------------- /src/app/tab1/tab1.page.spec.ts: -------------------------------------------------------------------------------- 1 | import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; 2 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 3 | 4 | import { Tab1Page } from './tab1.page'; 5 | 6 | describe('Tab1Page', () => { 7 | let component: Tab1Page; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(async(() => { 11 | TestBed.configureTestingModule({ 12 | declarations: [Tab1Page], 13 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 14 | }).compileComponents(); 15 | })); 16 | 17 | beforeEach(() => { 18 | fixture = TestBed.createComponent(Tab1Page); 19 | component = fixture.componentInstance; 20 | fixture.detectChanges(); 21 | }); 22 | 23 | it('should create', () => { 24 | expect(component).toBeTruthy(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/app/tab1/tab1.page.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { Router, NavigationStart } from '@angular/router'; 3 | import { Storage } from '@ionic/storage'; 4 | import { StatusBar } from '@ionic-native/status-bar/ngx'; 5 | import { AlertController } from '@ionic/angular'; 6 | import { Platform } from '@ionic/angular'; 7 | import { BarcodeScanner } from '@ionic-native/barcode-scanner/ngx'; 8 | import { SurveyDataService } from '../services/survey-data.service'; 9 | import { StudyTasksService } from '../services/study-tasks.service'; 10 | import { SurveyCacheService } from '../services/survey-cache.service'; 11 | import { UuidService } from '../services/uuid.service'; 12 | import { LoadingService } from '../services/loading-service.service'; 13 | import { NotificationsService } from '../services/notifications.service'; 14 | import { LocalNotifications } from '@ionic-native/local-notifications/ngx'; 15 | import * as moment from 'moment'; 16 | import { TranslateConfigService } from '../translate-config.service'; 17 | import {TranslateService} from '@ngx-translate/core'; 18 | 19 | @Component({ 20 | selector: 'app-tab1', 21 | templateUrl: 'tab1.page.html', 22 | styleUrls: ['tab1.page.scss'] 23 | }) 24 | export class Tab1Page { 25 | 26 | // resume event subscription 27 | resumeEvent: any 28 | // flag to display enrol options 29 | hideEnrolOptions: boolean = true 30 | // track whether the user is currently enrolled in a study 31 | isEnrolledInStudy: boolean = false 32 | // stores the details of the study 33 | study: any = null 34 | // stores the list of tasks to be completed by the user 35 | task_list: Array = new Array() 36 | // dark mode 37 | darkMode: boolean = false 38 | 39 | //translations loaded from the appropriate language file 40 | // defaults are provided but will be overridden if language file 41 | // is loaded successfully 42 | translations: any = { 43 | "btn_cancel": "Cancel", 44 | "btn_dismiss": "Dismiss", 45 | "btn_enrol": "Enrol", 46 | "btn_enter-url": "Enter URL", 47 | "btn_study-id": "Study ID", 48 | "error_loading-qr-code": "We couldn't load your study. Please check your internet connection and ensure you are scanning the correct code.", 49 | "error_loading-study": "We couldn't load your study. Please check your internet connection and ensure you are entering the correct URL.", 50 | "heading_error": "Oops...", 51 | "label_loading": "Loading...", 52 | "msg_caching": "Downloading media for offline use - please wait!", 53 | "msg_camera": "Camera permission is required to scan QR codes. You can allow this permission in Settings." 54 | } 55 | 56 | safeURL: string; 57 | 58 | // the current language of the device 59 | selectedLanguage: string; 60 | 61 | constructor(private barcodeScanner : BarcodeScanner, 62 | private surveyDataService : SurveyDataService, 63 | private notificationsService : NotificationsService, 64 | private surveyCacheService : SurveyCacheService, 65 | private studyTasksService : StudyTasksService, 66 | private uuidService : UuidService, 67 | private router : Router, 68 | private platform : Platform, 69 | private statusBar : StatusBar, 70 | private loadingService : LoadingService, 71 | private alertController : AlertController, 72 | private localNotifications : LocalNotifications, 73 | private storage : Storage, 74 | private translateConfigService: TranslateConfigService, 75 | private translate: TranslateService) { 76 | // get the default language of the device 77 | this.selectedLanguage = this.translateConfigService.getDefaultLanguage() 78 | } 79 | 80 | colorTest(systemInitiatedDark) { 81 | if (systemInitiatedDark.matches) { 82 | this.darkMode = true 83 | } else { 84 | this.darkMode = false 85 | } 86 | } 87 | 88 | ngOnInit() { 89 | // set statusBar to be visible on Android 90 | this.statusBar.styleLightContent() 91 | this.statusBar.backgroundColorByHexString('#0F2042') 92 | 93 | // need to subscribe to this event in order 94 | // to ensure that the page will refresh every 95 | // time it is navigated to because ionViewWillEnter() 96 | // is not called when navigating here from other pages 97 | this.router.events.subscribe(event => { 98 | if(event instanceof NavigationStart) { 99 | if(event.url == '/') { 100 | if (!this.loadingService.isLoading) this.ionViewWillEnter() 101 | } 102 | } 103 | }); 104 | 105 | // trigger this to run every time the app is resumed from the background 106 | this.resumeEvent = this.platform.resume.subscribe(() => { 107 | if (this.router.url === "/tabs/tab1") { 108 | if (!this.loadingService.isLoading) this.ionViewWillEnter() 109 | } 110 | }) 111 | } 112 | 113 | ionViewWillEnter() { 114 | 115 | // check if dark mode 116 | if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { 117 | // dark mode 118 | this.darkMode = true 119 | } else { 120 | this.darkMode = false 121 | } 122 | 123 | // load the correct translations for dynamic labels/messages 124 | const labels = [ 125 | "btn_cancel", 126 | "btn_dismiss", 127 | "btn_enrol", 128 | "btn_enter-url", 129 | "btn_study-id", 130 | "error_loading-qr-code", 131 | "error_loading-study", 132 | "heading_error", 133 | "label_loading", 134 | "msg_caching", 135 | "msg_camera" 136 | ]; 137 | this.translate.get(labels).subscribe(res => { this.translations = res; }) 138 | 139 | this.localNotifications.requestPermission() 140 | 141 | this.loadingService.isCaching = false 142 | this.loadingService.present(this.translations["label_loading"]) 143 | 144 | this.hideEnrolOptions = true 145 | this.isEnrolledInStudy = false 146 | 147 | 148 | // check if user is currently enrolled in study 149 | Promise.all([this.storage.get("current-study")]).then(values => { 150 | 151 | const studyObject = values[0] 152 | if (studyObject !== null) { 153 | 154 | // convert the study to a JSON object 155 | this.study = JSON.parse(studyObject) 156 | 157 | // log the user visiting this tab 158 | this.surveyDataService.logPageVisitToServer({ 159 | timestamp: moment().format(), 160 | milliseconds: moment().valueOf(), 161 | page: 'home', 162 | event: 'entry', 163 | module_index: -1 164 | }) 165 | 166 | // attempt to upload any pending logs and survey data 167 | this.surveyDataService.uploadPendingData("pending-log") 168 | this.surveyDataService.uploadPendingData("pending-data") 169 | 170 | // set up next round of notifications 171 | this.notificationsService.setNext30Notifications() 172 | 173 | // load the study tasks 174 | this.loadStudyDetails() 175 | } else { 176 | this.hideEnrolOptions = false 177 | 178 | this.loadingService.dismiss() 179 | } 180 | }); 181 | 182 | // on first run, generate a UUID for the user 183 | // and set the notifications-enabled to true 184 | this.storage.get('uuid-set').then((uuidSet) => { 185 | if (!uuidSet) { 186 | // set a UUID 187 | const uuid = this.uuidService.generateUUID("") 188 | this.storage.set('uuid', uuid) 189 | // set a flag that UUID was set 190 | this.storage.set('uuid-set', true) 191 | // set a flag that notifications are enabled 192 | this.storage.set('notifications-enabled', true) 193 | } 194 | }) 195 | } 196 | 197 | /** 198 | * Lifecycle event called when the current page is about to become paused/closed 199 | */ 200 | ionViewWillLeave() { 201 | if (this.isEnrolledInStudy) { 202 | // log the user exiting this tab 203 | this.surveyDataService.logPageVisitToServer({ 204 | timestamp: moment().format(), 205 | milliseconds: moment().valueOf(), 206 | page: 'home', 207 | event: 'exit', 208 | module_index: -1 209 | }) 210 | 211 | // attempt to upload any pending logs and survey data 212 | this.surveyDataService.uploadPendingData("pending-log") 213 | this.surveyDataService.uploadPendingData("pending-data") 214 | } 215 | } 216 | 217 | /** 218 | * Attempt to download a study from the URL scanned/entered by a user 219 | * @param url The URL to attempt to download a study from 220 | */ 221 | attemptToDownloadStudy(url, isQRCode) { 222 | // show loading bar 223 | this.loadingService.isCaching = false 224 | this.loadingService.present(this.translations["label_loading"]) 225 | 226 | this.surveyDataService.getRemoteData(url).then(data => { 227 | 228 | // check if the data received from the URL contains JSON properties/modules 229 | // in order to determine if it's a schema study before continuing 230 | let validStudy = false 231 | try { 232 | // checks if the returned text is parseable as JSON, and whether it contains 233 | // some of the key fields used by schema so it can determine whether it is 234 | // actually a schema study URL 235 | validStudy = JSON.parse(data['data']).properties !== undefined 236 | && JSON.parse(data['data']).modules !== undefined 237 | && JSON.parse(data['data']).properties.study_id !== undefined 238 | } catch(e) { 239 | validStudy = false 240 | } 241 | if (validStudy) { 242 | this.enrolInStudy(data) 243 | } else { 244 | this.loadingService.dismiss() 245 | this.displayEnrolError(isQRCode) 246 | } 247 | }); 248 | } 249 | 250 | /** 251 | * Uses the barcode scanner to enrol in a study 252 | */ 253 | async scanBarcode() { 254 | this.barcodeScanner.scan().then(barcodeData => { 255 | if (!barcodeData.cancelled) { 256 | this.attemptToDownloadStudy(barcodeData.text, true) 257 | } 258 | }).catch(err => { 259 | this.loadingService.dismiss() 260 | this.displayBarcodeError() 261 | }); 262 | } 263 | 264 | /** 265 | * Handles the alert dialog to enrol via URL 266 | */ 267 | async enterURL() { 268 | const alert = await this.alertController.create({ 269 | header: this.translations["btn_enter-url"], 270 | cssClass: 'alertStyle', 271 | inputs: [ 272 | { 273 | name: 'url', 274 | type: 'url', 275 | placeholder: 'e.g. https://bit.ly/2Q4O9jI', 276 | value: 'https://' 277 | } 278 | ], 279 | buttons: [ 280 | { 281 | text: this.translations["btn_cancel"], 282 | role: 'cancel', 283 | cssClass: 'secondary' 284 | }, { 285 | text: this.translations["btn_enrol"], 286 | handler: response => { 287 | this.attemptToDownloadStudy(response.url, false) 288 | } 289 | } 290 | ] 291 | }) 292 | 293 | await alert.present() 294 | } 295 | 296 | /** 297 | * 298 | * Handles the alert dialog to enrol via Study ID 299 | */ 300 | async enterStudyID() { 301 | const alert = await this.alertController.create({ 302 | header: this.translations["btn_study-id"], 303 | cssClass: 'alertStyle', 304 | inputs: [ 305 | { 306 | name: 'id', 307 | type: 'text', 308 | placeholder: 'e.g. STUDY01' 309 | } 310 | ], 311 | buttons: [ 312 | { 313 | text: this.translations["btn_cancel"], 314 | role: 'cancel', 315 | cssClass: 'secondary' 316 | }, { 317 | text: this.translations["btn_enrol"], 318 | handler: response => { 319 | // create URL for study 320 | const url = "https://getschema.app/study.php?study_id=" + response.id 321 | this.attemptToDownloadStudy(url, false) 322 | } 323 | } 324 | ] 325 | }) 326 | 327 | await alert.present() 328 | } 329 | 330 | /** 331 | * Enrols the user in the study, sets up notifications and tasks 332 | * @param data A data object returned from the server to represent a study object 333 | */ 334 | enrolInStudy(data) { 335 | this.isEnrolledInStudy = true 336 | this.hideEnrolOptions = true 337 | 338 | // convert received data to JSON object 339 | this.study = JSON.parse(data['data']); 340 | 341 | // set the enrolled date 342 | this.storage.set('enrolment-date', new Date()) 343 | 344 | // set an enrolled flag and save the JSON for the current study 345 | this.storage.set('current-study', JSON.stringify(this.study)).then(() => { 346 | // log the enrolment event 347 | this.surveyDataService.logPageVisitToServer({ 348 | timestamp: moment().format(), 349 | milliseconds: moment().valueOf(), 350 | page: 'home', 351 | event: 'enrol', 352 | module_index: -1 353 | }) 354 | 355 | // cache all media files if this study has set this property to true 356 | if (this.study.properties.cache === true) { 357 | 358 | this.loadingService.dismiss().then(() => { 359 | this.loadingService.isCaching = true 360 | this.loadingService.present(this.translations["msg_caching"]) 361 | }) 362 | this.surveyCacheService.cacheAllMedia(this.study) 363 | } 364 | 365 | // setup the study task objects 366 | this.studyTasksService.generateStudyTasks(this.study) 367 | 368 | // setup the notifications 369 | this.notificationsService.setNext30Notifications() 370 | 371 | this.loadStudyDetails() 372 | }); 373 | } 374 | 375 | /** 376 | * Loads the details of the current study, including overdue tasks 377 | */ 378 | loadStudyDetails() { 379 | //this.jsonText = this.study['properties'].study_name; 380 | this.studyTasksService.getTaskDisplayList().then(tasks => { 381 | this.task_list = tasks; 382 | 383 | for (let i = 0; i < this.task_list.length; i++) { 384 | this.task_list[i].moment = moment(this.task_list[i].locale).fromNow() 385 | } 386 | 387 | // show the study tasks 388 | this.isEnrolledInStudy = true 389 | this.hideEnrolOptions = true 390 | 391 | // reverse the order of the tasks list to show oldest first 392 | this.sortTasksList() 393 | 394 | // hide loading controller if not caching 395 | if (!this.loadingService.isCaching) { 396 | setTimeout(() => { 397 | this.loadingService.dismiss() 398 | }, 1000) 399 | } 400 | }) 401 | } 402 | 403 | /** 404 | * Displays an alert to indicate that something went wrong during study enrolment 405 | * @param isQRCode Denotes whether the error was caused via QR code enrolment 406 | */ 407 | async displayEnrolError(isQRCode) { 408 | const msg = isQRCode ? "We couldn't load your study. Please check your internet connection and ensure you are scanning the correct code." : "We couldn't load your study. Please check your internet connection and ensure you are entering the correct URL or ID." 409 | const alert = await this.alertController.create({ 410 | header: "Oops...", 411 | message: msg, 412 | cssClass: 'alertStyle', 413 | buttons: ["Dismiss"] 414 | }); 415 | await alert.present() 416 | } 417 | 418 | /** 419 | * Displays a message when camera permission is not allowed 420 | */ 421 | async displayBarcodeError() { 422 | const msg = "Camera permission is required to scan QR codes. You must allow this permission if you wish to use this feature."; 423 | const alert = await this.alertController.create({ 424 | header: "Permission Required", 425 | cssClass: 'alertStyle', 426 | message: msg, 427 | buttons: ["Dismiss"] 428 | }) 429 | await alert.present() 430 | } 431 | 432 | /** 433 | * Reverses the list of tasks for sorting purposes 434 | */ 435 | sortTasksList() { 436 | this.task_list.reverse() 437 | } 438 | 439 | /** 440 | * Refreshes the list of tasks 441 | */ 442 | doRefresh(refresher) { 443 | if (!this.loadingService.isLoading) this.ionViewWillEnter() 444 | setTimeout(() => { 445 | refresher.target.complete() 446 | }, 250) 447 | } 448 | } -------------------------------------------------------------------------------- /src/app/tab2/tab2.module.ts: -------------------------------------------------------------------------------- 1 | import { IonicModule } from '@ionic/angular'; 2 | import { RouterModule } from '@angular/router'; 3 | import { NgModule } from '@angular/core'; 4 | import { CommonModule } from '@angular/common'; 5 | import { FormsModule } from '@angular/forms'; 6 | import { Tab2Page } from './tab2.page'; 7 | import { ChartsModule } from 'ng2-charts'; 8 | import { TranslateModule } from '@ngx-translate/core'; 9 | 10 | @NgModule({ 11 | imports: [ 12 | IonicModule, 13 | CommonModule, 14 | FormsModule, 15 | ChartsModule, 16 | TranslateModule.forChild(), 17 | RouterModule.forChild([{ path: '', component: Tab2Page }]) 18 | ], 19 | declarations: [Tab2Page] 20 | }) 21 | export class Tab2PageModule {} 22 | -------------------------------------------------------------------------------- /src/app/tab2/tab2.page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | schema 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |

15 |

16 | {{ 'placeholder_my-progress' | translate:params }}

17 |
18 |
19 | 20 |
21 | {{graph.header}} 22 | 29 | 30 | 31 | 32 |

33 |
34 |
35 |
36 | 37 |
38 | 39 | 40 | Recently Completed 41 | 42 | 43 | 44 | 45 | 46 | 47 | {{record.task_name}} 48 | 49 | 50 | {{record.moment_time}} 51 | 52 | 53 |
54 |
55 | -------------------------------------------------------------------------------- /src/app/tab2/tab2.page.scss: -------------------------------------------------------------------------------- 1 | #stats-icon { 2 | font-size: 3em; 3 | } 4 | 5 | .graph-style { 6 | margin-left: 10px; 7 | margin-right: 10px; 8 | padding-right: 20px; 9 | } 10 | 11 | .blurb-style { 12 | font-size: 13px; 13 | color: #000000; 14 | } 15 | 16 | p { 17 | white-space: pre-wrap; 18 | } 19 | -------------------------------------------------------------------------------- /src/app/tab2/tab2.page.spec.ts: -------------------------------------------------------------------------------- 1 | import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; 2 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 3 | 4 | import { Tab2Page } from './tab2.page'; 5 | 6 | describe('Tab2Page', () => { 7 | let component: Tab2Page; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(async(() => { 11 | TestBed.configureTestingModule({ 12 | declarations: [Tab2Page], 13 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 14 | }).compileComponents(); 15 | })); 16 | 17 | beforeEach(() => { 18 | fixture = TestBed.createComponent(Tab2Page); 19 | component = fixture.componentInstance; 20 | fixture.detectChanges(); 21 | }); 22 | 23 | it('should create', () => { 24 | expect(component).toBeTruthy(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/app/tab2/tab2.page.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { Storage } from '@ionic/storage'; 3 | import { Chart } from 'chart.js'; 4 | import { ChartsModule } from 'ng2-charts'; 5 | import * as moment from 'moment'; 6 | import { SurveyDataService } from '../services/survey-data.service'; 7 | import { StudyTasksService } from '../services/study-tasks.service'; 8 | import { TranslateConfigService } from '../translate-config.service'; 9 | 10 | @Component({ 11 | selector: 'app-tab2', 12 | templateUrl: 'tab2.page.html', 13 | styleUrls: ['tab2.page.scss'] 14 | }) 15 | export class Tab2Page { 16 | 17 | // array to store the graphs 18 | graphs: Array = new Array() 19 | 20 | // array to store the history 21 | history: Array = new Array() 22 | 23 | // flag for study enrolment 24 | enrolledInStudy: boolean = false 25 | 26 | // study object JSON 27 | studyJSON: any 28 | 29 | // current study day 30 | studyDay: number 31 | 32 | // the current language of the device 33 | selectedLanguage: string 34 | 35 | // graph options 36 | chartOptions: any = { 37 | responsive: true, 38 | maintainAspectRatio: true, 39 | scales: { 40 | xAxes: [{ 41 | ticks: { 42 | fontSize: 6, 43 | }, 44 | barThickness: 20 45 | }], 46 | yAxes: [{ 47 | ticks: { 48 | fontSize: 8, 49 | beginAtZero: true 50 | } 51 | }] 52 | } 53 | } 54 | 55 | // graph colours 56 | chartColors: Array = [ 57 | { 58 | backgroundColor: 'rgba(4,153,139,0.6)', 59 | borderColor: 'rgba(148,159,177,1)', 60 | pointBackgroundColor: 'rgba(148,159,177,1)', 61 | pointBorderColor: '#fff', 62 | pointHoverBackgroundColor: '#fff', 63 | pointHoverBorderColor: 'rgba(148,159,177,0.8)' 64 | }, 65 | { 66 | backgroundColor: 'rgba(77,83,96,0.2)', 67 | borderColor: 'rgba(77,83,96,1)', 68 | pointBackgroundColor: 'rgba(77,83,96,1)', 69 | pointBorderColor: '#fff', 70 | pointHoverBackgroundColor: '#fff', 71 | pointHoverBorderColor: 'rgba(77,83,96,1)' 72 | }, 73 | { 74 | backgroundColor: 'rgba(148,159,177,0.2)', 75 | borderColor: 'rgba(148,159,177,1)', 76 | pointBackgroundColor: 'rgba(148,159,177,1)', 77 | pointBorderColor: '#fff', 78 | pointHoverBackgroundColor: '#fff', 79 | pointHoverBorderColor: 'rgba(148,159,177,0.8)' 80 | } 81 | ] 82 | 83 | constructor(private storage: Storage, 84 | private studyTasksService: StudyTasksService, 85 | private surveyDataService: SurveyDataService, 86 | private translateConfigService: TranslateConfigService) { 87 | // get the default language of the device 88 | this.selectedLanguage = this.translateConfigService.getDefaultLanguage() 89 | } 90 | 91 | ionViewWillEnter() { 92 | 93 | this.graphs = [] 94 | this.history = [] 95 | this.enrolledInStudy = false 96 | 97 | Promise.all([this.storage.get("current-study"), this.storage.get("enrolment-date")]).then(values => { 98 | const studyObject = values[0] 99 | const enrolmentDate = values[1] 100 | 101 | if (studyObject !== null) { 102 | 103 | this.studyJSON = JSON.parse(studyObject) 104 | this.enrolledInStudy = true 105 | 106 | // calculate the study day 107 | this.studyDay = this.diffDays(new Date(enrolmentDate), new Date()) 108 | 109 | // log the user visiting this tab 110 | this.surveyDataService.logPageVisitToServer({ 111 | timestamp: moment().format(), 112 | milliseconds: moment().valueOf(), 113 | page: 'my-progress', 114 | event: 'entry', 115 | module_index: -1 116 | }) 117 | 118 | // check if any graphs are available and add history items 119 | this.studyTasksService.getAllTasks().then(tasks => { 120 | // get all entries for history 121 | for (let i = 0; i < tasks.length; i++) { 122 | if (tasks[i].completed) { 123 | let historyItem = { 124 | task_name: tasks[i].name.replace(/<\/?[^>]+(>|$)/g, ""), 125 | moment_time: moment(tasks[i].response_time).fromNow(), //format("Do MMM, YYYY").fromNow() 126 | response_time: new Date(tasks[i].response_time) 127 | } 128 | this.history.unshift(historyItem); 129 | } 130 | } 131 | // sort the history array by completion time 132 | this.history.sort(function(x, y) { 133 | if (x.response_time > y.response_time) { 134 | return -1 135 | } 136 | if (x.response_time < y.response_time) { 137 | return 1 138 | } 139 | return 0 140 | }); 141 | 142 | // get all graphs 143 | for (let i = 0; i < this.studyJSON.modules.length; i++) { 144 | const graph = this.studyJSON.modules[i].graph 145 | const study_name = this.studyJSON.modules[i].name 146 | const graph_header = this.studyJSON.modules[i].name 147 | 148 | // if the module is to display a graph 149 | if (graph.display) { 150 | // get the variable to graph 151 | const variableToGraph = graph.variable 152 | 153 | // store the labels and data for this module 154 | const task_labels = [] 155 | const task_data = [] 156 | 157 | const graph_title = graph.title 158 | const graph_blurb = graph.blurb 159 | const graph_type = graph.type 160 | const graph_maxpoints = -(graph.max_points) 161 | 162 | // loop through each study_task 163 | for (let task in tasks) { 164 | // check if the task is this task 165 | if (tasks[task].name === study_name) { 166 | if (tasks[task].completed) { 167 | // get the variable we are to graph 168 | for (let k in tasks[task].responses) { 169 | if (k === variableToGraph) { 170 | // format the response time 171 | const response_time = moment(tasks[task].response_time).format("MMM Do, h:mma") 172 | task_labels.push(response_time) 173 | task_data.push(tasks[task].responses[k]) 174 | break 175 | } 176 | } 177 | } 178 | } 179 | } 180 | 181 | // create a new graph object 182 | const graphObj = { 183 | data: [{ data: task_data.slice(graph_maxpoints), label: graph_title }], 184 | labels: task_labels.slice(graph_maxpoints), 185 | options: this.chartOptions, 186 | colors: this.chartColors, 187 | legend: graph_title, 188 | type: graph_type, 189 | blurb: graph_blurb, 190 | header: graph_header 191 | } 192 | 193 | // if the task had any data to graph, push it 194 | if (task_data.length > 0) { 195 | this.graphs.push(graphObj) 196 | } 197 | } 198 | } 199 | }) 200 | } 201 | }) 202 | } 203 | 204 | diffDays(d1, d2) 205 | { 206 | let ndays = 0 207 | const tv1 = d1.valueOf() // msec since 1970 208 | const tv2 = d2.valueOf() 209 | 210 | ndays = (tv2 - tv1) / 1000 / 86400 211 | ndays = Math.round(ndays - 0.5) 212 | return ndays 213 | } 214 | 215 | async ionViewWillLeave() { 216 | if (this.enrolledInStudy) { 217 | this.surveyDataService.logPageVisitToServer({ 218 | timestamp: moment().format(), 219 | milliseconds: moment().valueOf(), 220 | page: 'my-progress', 221 | event: 'exit', 222 | module_index: -1 223 | }) 224 | } 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/app/tab3/tab3.module.ts: -------------------------------------------------------------------------------- 1 | import { IonicModule } from '@ionic/angular'; 2 | import { RouterModule } from '@angular/router'; 3 | import { NgModule } from '@angular/core'; 4 | import { CommonModule } from '@angular/common'; 5 | import { FormsModule } from '@angular/forms'; 6 | import { Tab3Page } from './tab3.page'; 7 | import { TranslateModule } from '@ngx-translate/core'; 8 | 9 | @NgModule({ 10 | imports: [ 11 | IonicModule, 12 | CommonModule, 13 | FormsModule, 14 | TranslateModule.forChild(), 15 | RouterModule.forChild([{ path: '', component: Tab3Page }]) 16 | ], 17 | declarations: [Tab3Page] 18 | }) 19 | export class Tab3PageModule {} 20 | -------------------------------------------------------------------------------- /src/app/tab3/tab3.page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | schema 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | User ID 14 | 15 | {{this.uuid}} 16 | 17 | 18 | 19 | Notifications 20 | 21 | Enable Notifications 22 | 23 | 24 |
25 | About this study 26 | 27 | 28 | 29 | 30 |
31 |
32 | Support 33 | 34 | 35 | Contact us by email 36 | 37 | 38 | 39 | 40 | Support website 41 | 42 | 43 |
44 |
45 | Ethics Information 46 | 47 | 48 | 49 | 50 | 51 | 52 | Plain Language Statement 53 | 54 | 55 |
56 | Withdraw 57 | 58 | 59 | Withdraw from study 60 | 61 | 62 |
63 | 64 |
-------------------------------------------------------------------------------- /src/app/tab3/tab3.page.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/src/app/tab3/tab3.page.scss -------------------------------------------------------------------------------- /src/app/tab3/tab3.page.spec.ts: -------------------------------------------------------------------------------- 1 | import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; 2 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 3 | 4 | import { Tab3Page } from './tab3.page'; 5 | 6 | describe('Tab3Page', () => { 7 | let component: Tab3Page; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(async(() => { 11 | TestBed.configureTestingModule({ 12 | declarations: [Tab3Page], 13 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 14 | }).compileComponents(); 15 | })); 16 | 17 | beforeEach(() => { 18 | fixture = TestBed.createComponent(Tab3Page); 19 | component = fixture.componentInstance; 20 | fixture.detectChanges(); 21 | }); 22 | 23 | it('should create', () => { 24 | expect(component).toBeTruthy(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/app/tab3/tab3.page.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { Storage } from '@ionic/storage'; 3 | import { NavController, AlertController } from '@ionic/angular'; 4 | import { NotificationsService } from '../services/notifications.service'; 5 | import { InAppBrowser } from '@ionic-native/in-app-browser/ngx'; 6 | import * as moment from 'moment'; 7 | import { TranslateConfigService } from '../translate-config.service'; 8 | import { SurveyDataService } from '../services/survey-data.service'; 9 | 10 | @Component({ 11 | selector: 'app-tab3', 12 | templateUrl: 'tab3.page.html', 13 | styleUrls: ['tab3.page.scss'] 14 | }) 15 | export class Tab3Page { 16 | 17 | // stores the user's UUID 18 | uuid : String 19 | 20 | // flag to track whether the user is in a study 21 | isEnrolled : boolean = false 22 | 23 | // flag to track whether notifications are enabled 24 | notificationsEnabled : boolean = true; 25 | 26 | // the current language of the device 27 | selectedLanguage : string; 28 | 29 | // store a reference to the study object 30 | // empty template used prior to loading data 31 | study : any = { 32 | properties: { 33 | study_name: "", 34 | instructions: "", 35 | support_email: "", 36 | support_url: "", 37 | ethics: "", 38 | pls: "" 39 | } 40 | } 41 | 42 | 43 | constructor(private storage: Storage, 44 | private navController: NavController, 45 | private alertController: AlertController, 46 | private iab: InAppBrowser, 47 | private notificsationsService: NotificationsService, 48 | private translateConfigService: TranslateConfigService, 49 | private surveyDataService: SurveyDataService) { 50 | // get the default language of the device 51 | this.selectedLanguage = this.translateConfigService.getDefaultLanguage(); 52 | } 53 | 54 | ionViewWillEnter() { 55 | 56 | this.isEnrolled = false; 57 | 58 | Promise.all([this.storage.get("current-study"), this.storage.get("uuid"), this.storage.get("notifications-enabled")]).then(values => { 59 | 60 | // check if user is currently enrolled in study 61 | // to show/hide additional options 62 | const studyObject = values[0] 63 | if (studyObject !== null) { 64 | this.isEnrolled = true 65 | this.study = JSON.parse(studyObject) 66 | } else { 67 | this.isEnrolled = false 68 | } 69 | 70 | // get the uuid from storage to display in the list 71 | this.uuid = values[1] 72 | 73 | // get the status of the notifications 74 | const notificationsEnabled = values[2] 75 | if (notificationsEnabled === null) this.notificationsEnabled = false 76 | else this.notificationsEnabled = notificationsEnabled 77 | 78 | // log the user visiting this tab 79 | this.surveyDataService.logPageVisitToServer({ 80 | timestamp: moment().format(), 81 | milliseconds: moment().valueOf(), 82 | page: 'settings', 83 | event: 'entry', 84 | module_index: -1 85 | }) 86 | }) 87 | } 88 | 89 | ionViewWillLeave() { 90 | if (this.isEnrolled) { 91 | this.surveyDataService.logPageVisitToServer({ 92 | timestamp: moment().format(), 93 | milliseconds: moment().valueOf(), 94 | page: 'settings', 95 | event: 'exit', 96 | module_index: -1 97 | }) 98 | } 99 | } 100 | 101 | /** 102 | * Display a dialog to withdraw from the study 103 | */ 104 | async withdrawFromStudy() { 105 | const alert = await this.alertController.create({ 106 | header: 'Are you sure?', 107 | message: 'By withdrawing, you will lose all progress.', 108 | cssClass: 'alertStyle', 109 | buttons: [ 110 | { 111 | text: 'Cancel', 112 | role: 'cancel' 113 | }, { 114 | text: 'Withdraw', 115 | handler: () => { 116 | // log a withdraw event to the server 117 | this.surveyDataService.logPageVisitToServer({ 118 | timestamp: moment().format(), 119 | milliseconds: moment().valueOf(), 120 | page: 'settings', 121 | event: 'withdraw', 122 | module_index: -1 123 | }) 124 | // upload any pending logs and data 125 | this.surveyDataService.uploadPendingData('pending-log').then(() => { 126 | return this.surveyDataService.uploadPendingData('pending-data') 127 | }).then(() => { 128 | return this.storage.remove('current-study') 129 | // then remove all the pending study tasks from storage 130 | }).then(() => { 131 | return this.storage.remove('study-tasks') 132 | // then cancel all remaining notifications and navigate to home 133 | }).then(() => { 134 | // cancel all notifications 135 | this.notificsationsService.cancelAllNotifications() 136 | // navigate to the home tab 137 | this.navController.navigateRoot('/') 138 | }); 139 | } 140 | } 141 | ] 142 | }); 143 | 144 | await alert.present() 145 | } 146 | 147 | /** 148 | * Enables/disables the notifications 149 | */ 150 | toggleNotifications() { 151 | // update the notifications flag 152 | this.storage.set('notifications-enabled', this.notificationsEnabled) 153 | // set the next 30 notifications (cancels all notifications before setting them if enabled) 154 | this.notificsationsService.setNext30Notifications() 155 | } 156 | 157 | /** 158 | * Opens the support website for the current study in a web browser 159 | * @param support_url The current study's support website URL 160 | */ 161 | openSupportURL(support_url) { 162 | //window.location.href = support_url; 163 | const browser = this.iab.create(support_url, "_system") 164 | } 165 | 166 | /** 167 | * Opens a new email addressed to the current study's support email address 168 | * @param support_email The current study's support email address 169 | * @param study_name The current study's name 170 | */ 171 | openSupportEmail(support_email, study_name) { 172 | window.location.href = "mailto:"+support_email+"?subject=Support: "+study_name 173 | } 174 | 175 | } 176 | -------------------------------------------------------------------------------- /src/app/tabs/tabs.module.ts: -------------------------------------------------------------------------------- 1 | import { IonicModule } from '@ionic/angular'; 2 | import { NgModule } from '@angular/core'; 3 | import { CommonModule } from '@angular/common'; 4 | import { FormsModule } from '@angular/forms'; 5 | import { TranslateModule } from '@ngx-translate/core'; 6 | import { TabsPageRoutingModule } from './tabs.router.module'; 7 | 8 | import { TabsPage } from './tabs.page'; 9 | 10 | @NgModule({ 11 | imports: [ 12 | IonicModule, 13 | CommonModule, 14 | FormsModule, 15 | TabsPageRoutingModule, 16 | TranslateModule.forChild() 17 | ], 18 | declarations: [TabsPage] 19 | }) 20 | export class TabsPageModule {} 21 | -------------------------------------------------------------------------------- /src/app/tabs/tabs.page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Home 7 | 8 | 9 | 10 | 11 | My Progress 12 | 13 | 14 | 15 | 16 | Settings 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/app/tabs/tabs.page.scss: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app/tabs/tabs.page.spec.ts: -------------------------------------------------------------------------------- 1 | import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; 2 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 3 | 4 | import { TabsPage } from './tabs.page'; 5 | 6 | describe('TabsPage', () => { 7 | let component: TabsPage; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(async(() => { 11 | TestBed.configureTestingModule({ 12 | declarations: [TabsPage], 13 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 14 | }).compileComponents(); 15 | })); 16 | 17 | beforeEach(() => { 18 | fixture = TestBed.createComponent(TabsPage); 19 | component = fixture.componentInstance; 20 | fixture.detectChanges(); 21 | }); 22 | 23 | it('should create', () => { 24 | expect(component).toBeTruthy(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/app/tabs/tabs.page.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { TranslateConfigService } from '../translate-config.service'; 3 | 4 | @Component({ 5 | selector: 'app-tabs', 6 | templateUrl: 'tabs.page.html', 7 | styleUrls: ['tabs.page.scss'] 8 | }) 9 | export class TabsPage { 10 | 11 | // the current language of the device 12 | selectedLanguage: string 13 | 14 | constructor(private translateConfigService: TranslateConfigService){ 15 | // get the default language of the device 16 | this.selectedLanguage = this.translateConfigService.getDefaultLanguage() 17 | } 18 | } 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/app/tabs/tabs.router.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | import { TabsPage } from './tabs.page'; 4 | 5 | const routes: Routes = [ 6 | { 7 | path: 'tabs', 8 | component: TabsPage, 9 | children: [ 10 | { 11 | path: 'tab1', 12 | children: [ 13 | { 14 | path: '', 15 | loadChildren: '../tab1/tab1.module#Tab1PageModule' 16 | } 17 | ] 18 | }, 19 | { 20 | path: 'tab2', 21 | children: [ 22 | { 23 | path: '', 24 | loadChildren: '../tab2/tab2.module#Tab2PageModule' 25 | } 26 | ] 27 | }, 28 | { 29 | path: 'tab3', 30 | children: [ 31 | { 32 | path: '', 33 | loadChildren: '../tab3/tab3.module#Tab3PageModule' 34 | } 35 | ] 36 | }, 37 | { 38 | path: '', 39 | redirectTo: '/tabs/tab1', 40 | pathMatch: 'full' 41 | } 42 | ] 43 | }, 44 | { 45 | path: '', 46 | redirectTo: '/tabs/tab1', 47 | pathMatch: 'full' 48 | } 49 | ]; 50 | 51 | @NgModule({ 52 | imports: [ 53 | RouterModule.forChild(routes) 54 | ], 55 | exports: [RouterModule] 56 | }) 57 | export class TabsPageRoutingModule {} 58 | -------------------------------------------------------------------------------- /src/app/translate-config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { TranslateService } from '@ngx-translate/core'; 3 | 4 | @Injectable({ 5 | providedIn: 'root' 6 | }) 7 | export class TranslateConfigService { 8 | 9 | constructor(private translate: TranslateService) { } 10 | 11 | /** 12 | * Get the default language of the current device 13 | */ 14 | getDefaultLanguage(){ 15 | let language = this.translate.getBrowserLang(); 16 | this.translate.setDefaultLang(language); 17 | return language; 18 | } 19 | 20 | /** 21 | * Set the current language of the device 22 | * @param setLang The language to set 23 | */ 24 | setLanguage(setLang) { 25 | this.translate.use(setLang); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/assets/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "btn_cancel": "Cancel", 3 | "btn_dismiss": "Dismiss", 4 | "btn_enrol": "Enrol", 5 | "btn_enter-url": "Enter URL", 6 | "btn_scan-qr-code": "Scan QR Code", 7 | "btn_study-id": "Study ID", 8 | "error_loading-qr-code": "We couldn't load your study. Please check your internet connection and ensure you are scanning the correct code.", 9 | "error_loading-study": "We couldn't load your study. Please check your internet connection and ensure you are entering the correct URL.", 10 | "heading_about-this-study": "About this study", 11 | "heading_error": "Oops...", 12 | "heading_ethics-information": "Ethics Information", 13 | "heading_notifications": "Notifications", 14 | "heading_permission-required": "Permission Required", 15 | "heading_recently-completed": "Recently Completed", 16 | "heading_support": "Support", 17 | "heading_user-id": "User ID", 18 | "heading_withdraw": "Withdraw", 19 | "label_contact-us-by-email": "Contact us by email", 20 | "label_enable-notifications": "Enable Notifications", 21 | "label_home": "Home", 22 | "label_loading": "Loading...", 23 | "label_my-progress": "My Progress", 24 | "label_plain-language-statement": "Plain Language Statement", 25 | "label_settings": "Settings", 26 | "label_support-website": "Support website", 27 | "label_withdraw-from-study": "Withdraw from study", 28 | "msg_caching": "Downloading media for offline use - please wait!", 29 | "msg_camera": "Camera permission is required to scan QR codes. You can allow this permission in Settings.", 30 | "placeholder_my-progress": "As you complete tasks, your progress will be updated in this tab." 31 | } -------------------------------------------------------------------------------- /src/assets/icon/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/src/assets/icon/favicon.png -------------------------------------------------------------------------------- /src/assets/icon/notification_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/src/assets/icon/notification_icon.png -------------------------------------------------------------------------------- /src/assets/imgs/dark_circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/src/assets/imgs/dark_circle.png -------------------------------------------------------------------------------- /src/assets/imgs/home-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/src/assets/imgs/home-icon.png -------------------------------------------------------------------------------- /src/assets/imgs/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/src/assets/imgs/icon.png -------------------------------------------------------------------------------- /src/assets/imgs/light_circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/src/assets/imgs/light_circle.png -------------------------------------------------------------------------------- /src/assets/imgs/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/src/assets/imgs/loading.gif -------------------------------------------------------------------------------- /src/assets/imgs/slide2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/src/assets/imgs/slide2.png -------------------------------------------------------------------------------- /src/assets/imgs/slider1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/src/assets/imgs/slider1.png -------------------------------------------------------------------------------- /src/assets/imgs/small-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/src/assets/imgs/small-icon.png -------------------------------------------------------------------------------- /src/assets/imgs/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schema-app/schema/45fd178792c9c2d4bc1c738e6adc35215c007f50/src/assets/imgs/spinner.gif -------------------------------------------------------------------------------- /src/assets/shapes.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /src/global.scss: -------------------------------------------------------------------------------- 1 | // http://ionicframework.com/docs/theming/ 2 | @import '~@ionic/angular/css/core.css'; 3 | @import '~@ionic/angular/css/normalize.css'; 4 | @import '~@ionic/angular/css/structure.css'; 5 | @import '~@ionic/angular/css/typography.css'; 6 | @import '~@ionic/angular/css/display.css'; 7 | @import '~@ionic/angular/css/padding.css'; 8 | @import '~@ionic/angular/css/float-elements.css'; 9 | @import '~@ionic/angular/css/text-alignment.css'; 10 | @import '~@ionic/angular/css/text-transformation.css'; 11 | @import '~@ionic/angular/css/flex-utils.css'; 12 | 13 | 14 | // schema themes 15 | .header-icon { 16 | max-height:16px; 17 | } 18 | 19 | .header-title-thin { 20 | font-weight:50; 21 | padding-left:5px; 22 | } -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | schema 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, '../coverage'), 20 | reports: ['html', 'lcovonly', 'text-summary'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | //(window).skipLocalNotificationReady = true; 8 | 9 | if (environment.production) { 10 | enableProdMode(); 11 | } 12 | 13 | platformBrowserDynamic().bootstrapModule(AppModule) 14 | .catch(err => console.log(err)); 15 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE9, IE10, IE11, and Chrome <55 requires all of the following polyfills. 22 | * This also includes Android Emulators with older versions of Chrome and Google Search/Googlebot 23 | */ 24 | 25 | // import 'core-js/es6/symbol'; 26 | // import 'core-js/es6/object'; 27 | // import 'core-js/es6/function'; 28 | // import 'core-js/es6/parse-int'; 29 | // import 'core-js/es6/parse-float'; 30 | // import 'core-js/es6/number'; 31 | // import 'core-js/es6/math'; 32 | // import 'core-js/es6/string'; 33 | // import 'core-js/es6/date'; 34 | // import 'core-js/es6/array'; 35 | // import 'core-js/es6/regexp'; 36 | // import 'core-js/es6/map'; 37 | // import 'core-js/es6/weak-map'; 38 | // import 'core-js/es6/set'; 39 | 40 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 41 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 42 | 43 | /** IE10 and IE11 requires the following for the Reflect API. */ 44 | // import 'core-js/es6/reflect'; 45 | 46 | /** 47 | * Web Animations `@angular/platform-browser/animations` 48 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 49 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 50 | */ 51 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 52 | 53 | /** 54 | * By default, zone.js will patch all possible macroTask and DomEvents 55 | * user can disable parts of macroTask/DomEvents patch by setting following flags 56 | * because those flags need to be set before `zone.js` being loaded, and webpack 57 | * will put import in the top of bundle, so user need to create a separate file 58 | * in this directory (for example: zone-flags.ts), and put the following flags 59 | * into that file, and then add the following code before importing zone.js. 60 | * import './zone-flags.ts'; 61 | * 62 | * The flags allowed in zone-flags.ts are listed here. 63 | * 64 | * The following flags will work for all browsers. 65 | * 66 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 67 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 68 | * (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 69 | * 70 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 71 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 72 | * 73 | * (window as any).__Zone_enable_cross_context_check = true; 74 | * 75 | */ 76 | 77 | /*************************************************************************************************** 78 | * Zone JS is required by default for Angular itself. 79 | */ 80 | import 'zone.js/dist/zone'; // Included with Angular CLI. 81 | 82 | 83 | /*************************************************************************************************** 84 | * APPLICATION IMPORTS 85 | */ 86 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/zone-testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | declare const require: any; 11 | 12 | // First, initialize the Angular testing environment. 13 | getTestBed().initTestEnvironment( 14 | BrowserDynamicTestingModule, 15 | platformBrowserDynamicTesting() 16 | ); 17 | // Then we find all the tests. 18 | const context = require.context('./', true, /\.spec\.ts$/); 19 | // And load the modules. 20 | context.keys().map(context); 21 | -------------------------------------------------------------------------------- /src/theme/variables.scss: -------------------------------------------------------------------------------- 1 | // Ionic Variables and Theming. For more info, please see: 2 | // http://ionicframework.com/docs/theming/ 3 | 4 | /** Ionic CSS Variables **/ 5 | :root { 6 | /** primary **/ 7 | --ion-color-primary: #0F2042; 8 | --ion-color-primary-rgb: 56, 128, 255; 9 | --ion-color-primary-contrast: #ffffff; 10 | --ion-color-primary-contrast-rgb: 255, 255, 255; 11 | --ion-color-primary-shade: #3171e0; 12 | --ion-color-primary-tint: #4c8dff; 13 | 14 | /** secondary **/ 15 | --ion-color-secondary: #04998b; 16 | --ion-color-secondary-rgb: 12, 209, 232; 17 | --ion-color-secondary-contrast: #ffffff; 18 | --ion-color-secondary-contrast-rgb: 255, 255, 255; 19 | --ion-color-secondary-shade: #00645b; 20 | --ion-color-secondary-tint: #00645b; 21 | 22 | /** tertiary **/ 23 | --ion-color-tertiary: #7044ff; 24 | --ion-color-tertiary-rgb: 112, 68, 255; 25 | --ion-color-tertiary-contrast: #ffffff; 26 | --ion-color-tertiary-contrast-rgb: 255, 255, 255; 27 | --ion-color-tertiary-shade: #633ce0; 28 | --ion-color-tertiary-tint: #7e57ff; 29 | 30 | /** success **/ 31 | --ion-color-success: #10dc60; 32 | --ion-color-success-rgb: 16, 220, 96; 33 | --ion-color-success-contrast: #ffffff; 34 | --ion-color-success-contrast-rgb: 255, 255, 255; 35 | --ion-color-success-shade: #0ec254; 36 | --ion-color-success-tint: #28e070; 37 | 38 | /** warning **/ 39 | --ion-color-warning: #ffce00; 40 | --ion-color-warning-rgb: 255, 206, 0; 41 | --ion-color-warning-contrast: #ffffff; 42 | --ion-color-warning-contrast-rgb: 255, 255, 255; 43 | --ion-color-warning-shade: #e0b500; 44 | --ion-color-warning-tint: #ffd31a; 45 | 46 | /** danger **/ 47 | --ion-color-danger: #f04141; 48 | --ion-color-danger-rgb: 245, 61, 61; 49 | --ion-color-danger-contrast: #ffffff; 50 | --ion-color-danger-contrast-rgb: 255, 255, 255; 51 | --ion-color-danger-shade: #d33939; 52 | --ion-color-danger-tint: #f25454; 53 | 54 | /** dark **/ 55 | --ion-color-dark: #222428; 56 | --ion-color-dark-rgb: 34, 34, 34; 57 | --ion-color-dark-contrast: #ffffff; 58 | --ion-color-dark-contrast-rgb: 255, 255, 255; 59 | --ion-color-dark-shade: #1e2023; 60 | --ion-color-dark-tint: #383a3e; 61 | 62 | /** medium **/ 63 | --ion-color-medium: #989aa2; 64 | --ion-color-medium-rgb: 152, 154, 162; 65 | --ion-color-medium-contrast: #ffffff; 66 | --ion-color-medium-contrast-rgb: 255, 255, 255; 67 | --ion-color-medium-shade: #86888f; 68 | --ion-color-medium-tint: #a2a4ab; 69 | 70 | /** light **/ 71 | --ion-color-light: #f4f5f8; 72 | --ion-color-light-rgb: 244, 244, 244; 73 | --ion-color-light-contrast: #000000; 74 | --ion-color-light-contrast-rgb: 0, 0, 0; 75 | --ion-color-light-shade: #d7d8da; 76 | --ion-color-light-tint: #f5f6f9; 77 | 78 | 79 | --ion-tab-bar-color: #ffffff; 80 | --ion-tab-bar-background: var(--ion-color-primary); 81 | --ion-tab-bar-color-selected: var(--ion-color-secondary); 82 | 83 | ion-segment-button { 84 | /*--background-checked: var(--ion-color-secondary);*/ 85 | --indicator-color:var(--ion-color-secondary); 86 | } 87 | 88 | /*.alertStyle { 89 | button.alert-button{ 90 | color: #000000 !important; 91 | } 92 | }*/ 93 | 94 | 95 | :focus{ 96 | outline: none !important; 97 | } 98 | 99 | } 100 | 101 | @media (prefers-color-scheme: dark) { 102 | /* 103 | * Dark Colors 104 | * ------------------------------------------- 105 | */ 106 | 107 | body { 108 | /** primary **/ 109 | --ion-color-primary: #0F2042; 110 | --ion-color-primary-rgb: 56, 128, 255; 111 | --ion-color-primary-contrast: #ffffff; 112 | --ion-color-primary-contrast-rgb: 255, 255, 255; 113 | --ion-color-primary-shade: #3171e0; 114 | --ion-color-primary-tint: #4c8dff; 115 | 116 | /** secondary **/ 117 | --ion-color-secondary: #04998b; 118 | --ion-color-secondary-rgb: 12, 209, 232; 119 | --ion-color-secondary-contrast: #ffffff; 120 | --ion-color-secondary-contrast-rgb: 255, 255, 255; 121 | --ion-color-secondary-shade: #00645b; 122 | --ion-color-secondary-tint: #00645b; 123 | 124 | /** tertiary **/ 125 | --ion-color-tertiary: #7044ff; 126 | --ion-color-tertiary-rgb: 112, 68, 255; 127 | --ion-color-tertiary-contrast: #ffffff; 128 | --ion-color-tertiary-contrast-rgb: 255, 255, 255; 129 | --ion-color-tertiary-shade: #633ce0; 130 | --ion-color-tertiary-tint: #7e57ff; 131 | 132 | /** success **/ 133 | --ion-color-success: #10dc60; 134 | --ion-color-success-rgb: 16, 220, 96; 135 | --ion-color-success-contrast: #ffffff; 136 | --ion-color-success-contrast-rgb: 255, 255, 255; 137 | --ion-color-success-shade: #0ec254; 138 | --ion-color-success-tint: #28e070; 139 | 140 | /** warning **/ 141 | --ion-color-warning: #ffce00; 142 | --ion-color-warning-rgb: 255, 206, 0; 143 | --ion-color-warning-contrast: #ffffff; 144 | --ion-color-warning-contrast-rgb: 255, 255, 255; 145 | --ion-color-warning-shade: #e0b500; 146 | --ion-color-warning-tint: #ffd31a; 147 | 148 | /** danger **/ 149 | --ion-color-danger: #f04141; 150 | --ion-color-danger-rgb: 245, 61, 61; 151 | --ion-color-danger-contrast: #ffffff; 152 | --ion-color-danger-contrast-rgb: 255, 255, 255; 153 | --ion-color-danger-shade: #d33939; 154 | --ion-color-danger-tint: #f25454; 155 | 156 | /** dark **/ 157 | --ion-color-dark: #222428; 158 | --ion-color-dark-rgb: 34, 34, 34; 159 | --ion-color-dark-contrast: #ffffff; 160 | --ion-color-dark-contrast-rgb: 255, 255, 255; 161 | --ion-color-dark-shade: #1e2023; 162 | --ion-color-dark-tint: #383a3e; 163 | 164 | /** medium **/ 165 | --ion-color-medium: #989aa2; 166 | --ion-color-medium-rgb: 152, 154, 162; 167 | --ion-color-medium-contrast: #ffffff; 168 | --ion-color-medium-contrast-rgb: 255, 255, 255; 169 | --ion-color-medium-shade: #86888f; 170 | --ion-color-medium-tint: #a2a4ab; 171 | 172 | /** light **/ 173 | --ion-color-light: #222428;; 174 | --ion-color-light-rgb: 152, 154, 162; 175 | --ion-color-light-contrast: #ffffff; 176 | --ion-color-light-contrast-rgb: 0, 0, 0; 177 | --ion-color-light-shade: #d7d8da; 178 | --ion-color-light-tint: #f5f6f9; 179 | 180 | ion-segment-button { 181 | /*--background-checked: var(--ion-color-secondary);*/ 182 | --indicator-color:var(--ion-color-secondary); 183 | } 184 | 185 | .alertStyle { 186 | button.alert-button{ 187 | color: #FFFFFF !important; 188 | } 189 | } 190 | 191 | 192 | :focus{ 193 | outline: none !important; 194 | } 195 | } 196 | 197 | /* 198 | * iOS Dark Theme 199 | * ------------------------------------------- 200 | */ 201 | 202 | .ios body, .ios footer { 203 | --ion-background-color: #000000; 204 | --ion-background-color-rgb: 0,0,0; 205 | 206 | --ion-text-color: #ffffff; 207 | --ion-text-color-rgb: 255,255,255; 208 | 209 | --ion-color-step-50: #0d0d0d; 210 | --ion-color-step-100: #1a1a1a; 211 | --ion-color-step-150: #262626; 212 | --ion-color-step-200: #333333; 213 | --ion-color-step-250: #404040; 214 | --ion-color-step-300: #4d4d4d; 215 | --ion-color-step-350: #595959; 216 | --ion-color-step-400: #666666; 217 | --ion-color-step-450: #737373; 218 | --ion-color-step-500: #808080; 219 | --ion-color-step-550: #8c8c8c; 220 | --ion-color-step-600: #999999; 221 | --ion-color-step-650: #a6a6a6; 222 | --ion-color-step-700: #b3b3b3; 223 | --ion-color-step-750: #bfbfbf; 224 | --ion-color-step-800: #cccccc; 225 | --ion-color-step-850: #d9d9d9; 226 | --ion-color-step-900: #e6e6e6; 227 | --ion-color-step-950: #f2f2f2; 228 | 229 | --ion-toolbar-background: #0d0d0d; 230 | 231 | --ion-item-background: #000000; 232 | } 233 | 234 | 235 | /* 236 | * Material Design Dark Theme 237 | * ------------------------------------------- 238 | */ 239 | 240 | .md body { 241 | --ion-background-color: #121212; 242 | --ion-background-color-rgb: 18,18,18; 243 | 244 | --ion-text-color: #ffffff; 245 | --ion-text-color-rgb: 255,255,255; 246 | 247 | --ion-border-color: #222222; 248 | 249 | --ion-color-step-50: #1e1e1e; 250 | --ion-color-step-100: #2a2a2a; 251 | --ion-color-step-150: #363636; 252 | --ion-color-step-200: #414141; 253 | --ion-color-step-250: #4d4d4d; 254 | --ion-color-step-300: #595959; 255 | --ion-color-step-350: #656565; 256 | --ion-color-step-400: #717171; 257 | --ion-color-step-450: #7d7d7d; 258 | --ion-color-step-500: #898989; 259 | --ion-color-step-550: #949494; 260 | --ion-color-step-600: #a0a0a0; 261 | --ion-color-step-650: #acacac; 262 | --ion-color-step-700: #b8b8b8; 263 | --ion-color-step-750: #c4c4c4; 264 | --ion-color-step-800: #d0d0d0; 265 | --ion-color-step-850: #dbdbdb; 266 | --ion-color-step-900: #e7e7e7; 267 | --ion-color-step-950: #f3f3f3; 268 | 269 | --ion-item-background: #1e1e1e; 270 | 271 | --ion-toolbar-background: #1f1f1f; 272 | 273 | --ion-tab-bar-background: #1f1f1f; 274 | } 275 | 276 | } -------------------------------------------------------------------------------- /src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "types": [] 6 | }, 7 | "exclude": [ 8 | "test.ts", 9 | "**/*.spec.ts" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "types": [ 6 | "jasmine", 7 | "node" 8 | ] 9 | }, 10 | "files": [ 11 | "test.ts", 12 | "polyfills.ts" 13 | ], 14 | "include": [ 15 | "**/*.spec.ts", 16 | "**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "module": "es2015", 9 | "moduleResolution": "node", 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "importHelpers": true, 13 | "target": "es5", 14 | "typeRoots": [ 15 | "node_modules/@types" 16 | ], 17 | "lib": [ 18 | "es2018", 19 | "dom" 20 | ] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "codelyzer" 4 | ], 5 | "rules": { 6 | "arrow-return-shorthand": true, 7 | "callable-types": true, 8 | "class-name": true, 9 | "comment-format": [ 10 | true, 11 | "check-space" 12 | ], 13 | "curly": true, 14 | "deprecation": { 15 | "severity": "warn" 16 | }, 17 | "eofline": true, 18 | "forin": true, 19 | "import-blacklist": [ 20 | true, 21 | "rxjs/Rx" 22 | ], 23 | "import-spacing": true, 24 | "indent": [ 25 | true, 26 | "spaces" 27 | ], 28 | "interface-over-type-literal": true, 29 | "label-position": true, 30 | "max-line-length": [ 31 | true, 32 | 140 33 | ], 34 | "member-access": false, 35 | "member-ordering": [ 36 | true, 37 | { 38 | "order": [ 39 | "static-field", 40 | "instance-field", 41 | "static-method", 42 | "instance-method" 43 | ] 44 | } 45 | ], 46 | "no-arg": true, 47 | "no-bitwise": true, 48 | "no-console": [ 49 | true, 50 | "debug", 51 | "info", 52 | "time", 53 | "timeEnd", 54 | "trace" 55 | ], 56 | "no-construct": true, 57 | "no-debugger": true, 58 | "no-duplicate-super": true, 59 | "no-empty": false, 60 | "no-empty-interface": true, 61 | "no-eval": true, 62 | "no-inferrable-types": [ 63 | true, 64 | "ignore-params" 65 | ], 66 | "no-misused-new": true, 67 | "no-non-null-assertion": true, 68 | "no-redundant-jsdoc": true, 69 | "no-shadowed-variable": true, 70 | "no-string-literal": false, 71 | "no-string-throw": true, 72 | "no-switch-case-fall-through": true, 73 | "no-trailing-whitespace": true, 74 | "no-unnecessary-initializer": true, 75 | "no-unused-expression": true, 76 | "no-use-before-declare": true, 77 | "no-var-keyword": true, 78 | "object-literal-sort-keys": false, 79 | "one-line": [ 80 | true, 81 | "check-open-brace", 82 | "check-catch", 83 | "check-else", 84 | "check-whitespace" 85 | ], 86 | "prefer-const": true, 87 | "quotemark": [ 88 | true, 89 | "single" 90 | ], 91 | "radix": true, 92 | "semicolon": [ 93 | true, 94 | "always" 95 | ], 96 | "triple-equals": [ 97 | true, 98 | "allow-null-check" 99 | ], 100 | "typedef-whitespace": [ 101 | true, 102 | { 103 | "call-signature": "nospace", 104 | "index-signature": "nospace", 105 | "parameter": "nospace", 106 | "property-declaration": "nospace", 107 | "variable-declaration": "nospace" 108 | } 109 | ], 110 | "unified-signatures": true, 111 | "variable-name": false, 112 | "whitespace": [ 113 | true, 114 | "check-branch", 115 | "check-decl", 116 | "check-operator", 117 | "check-separator", 118 | "check-type" 119 | ], 120 | "no-output-on-prefix": true, 121 | "use-input-property-decorator": true, 122 | "use-output-property-decorator": true, 123 | "use-host-property-decorator": true, 124 | "no-input-rename": true, 125 | "no-output-rename": true, 126 | "use-life-cycle-interface": true, 127 | "use-pipe-transform-interface": true, 128 | "directive-class-suffix": true 129 | } 130 | } 131 | --------------------------------------------------------------------------------