├── .gitignore ├── .idea ├── .name ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml ├── jarRepositories.xml ├── misc.xml └── vcs.xml ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── example │ │ └── intimesimple │ │ ├── ExampleInstrumentedTest.kt │ │ ├── LiveDataTestUtil.kt │ │ ├── TestUtil.kt │ │ └── WorkoutDatabaseTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── example │ │ │ └── intimesimple │ │ │ ├── BaseApplication.kt │ │ │ ├── MainActivity.kt │ │ │ ├── data │ │ │ └── local │ │ │ │ ├── AppDatabase.kt │ │ │ │ ├── AudioState.kt │ │ │ │ ├── TimerState.kt │ │ │ │ ├── Workout.kt │ │ │ │ ├── WorkoutDao.kt │ │ │ │ └── WorkoutState.kt │ │ │ ├── di │ │ │ ├── AppModule.kt │ │ │ ├── Qualifiers.kt │ │ │ └── ServiceModule.kt │ │ │ ├── repositories │ │ │ ├── PreferenceRepository.kt │ │ │ └── WorkoutRepository.kt │ │ │ ├── services │ │ │ └── TimerService.kt │ │ │ ├── ui │ │ │ ├── animations │ │ │ │ └── AnimationDefinitions.kt │ │ │ ├── composables │ │ │ │ ├── AnimatedDismiss.kt │ │ │ │ ├── DetailScreenTopBar.kt │ │ │ │ ├── TimerCircleComponent.kt │ │ │ │ ├── WorkoutAddScreen.kt │ │ │ │ ├── WorkoutDetailScreen.kt │ │ │ │ ├── WorkoutItem.kt │ │ │ │ ├── WorkoutListScreen.kt │ │ │ │ └── navigation │ │ │ │ │ ├── Navigation.kt │ │ │ │ │ └── Screens.kt │ │ │ ├── theme │ │ │ │ ├── Color.kt │ │ │ │ ├── Shape.kt │ │ │ │ └── Theme.kt │ │ │ └── viewmodels │ │ │ │ ├── WorkoutDetailViewModel.kt │ │ │ │ └── WorkoutListViewModel.kt │ │ │ └── utils │ │ │ ├── Constants.kt │ │ │ ├── ServiceUtils.kt │ │ │ └── Utils.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ ├── ic_alarm.xml │ │ └── ic_launcher_background.xml │ │ ├── font │ │ ├── robotocondensed_bold.ttf │ │ ├── robotocondensed_light.ttf │ │ └── robotocondensed_regular.ttf │ │ ├── layout │ │ └── activity_main.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── navigation │ │ └── nav_graph.xml │ │ ├── values-night │ │ └── themes.xml │ │ └── values │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── test │ └── java │ └── com │ └── example │ └── intimesimple │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── screenshots ├── mvvm.png └── preview.gif └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | INTimeSimple -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 20 | 22 | 23 | 135 | 136 | 138 | 139 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 20 | 21 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 20 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | 29 | 30 | 34 | 35 | 39 | 40 | 44 | 45 | 49 | 50 | 54 | 55 | 59 | 60 | 64 | 65 | 69 | 70 | 74 | 75 | 79 | 80 | 84 | 85 | 89 | 90 | 94 | 95 | 99 | 100 | 104 | 105 | 109 | 110 | 114 | 115 | 119 | 120 | 124 | 125 | 129 | 130 | 134 | 135 | 139 | 140 | 144 | 145 | 149 | 150 | 154 | 155 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## What's InTime? :hourglass_flowing_sand: 4 | 5 | InTime is an interval timer application using android jetpack components and a long running service. 6 | 7 | The purpose of this project is to learn and implement new android technologies and libraries. 8 | 9 | ## Used Libraries 10 | - [Compose](https://developer.android.com/jetpack/compose) 11 | - [Compose Navigation](https://developer.android.com/jetpack/compose/navigation) 12 | - [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel) 13 | - [LiveData](https://developer.android.com/topic/libraries/architecture/livedata) 14 | - [Kotlin Coroutines](https://github.com/Kotlin/kotlinx.coroutines) 15 | - [Dagger Hilt](https://dagger.dev/hilt/) 16 | - [Room](https://developer.android.com/topic/libraries/architecture/room) 17 | - [DataStore](https://developer.android.com/topic/libraries/architecture/datastore) 18 | 19 |
20 | 21 | 22 | 23 | ## Architecture 24 | This application uses the MVVM (Model View ViewModel) architecture and unidirectional data flow. 25 | 26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | 37 | ## Navigation 38 | Furthermore it is a single-activity application with compose-navigation handling the 39 | [navigation graph](https://github.com/p-hlp/InTimeAndroid/blob/master/app/src/main/java/com/example/intimesimple/ui/composables/navigation/Navigation.kt). 40 | ```kt 41 | NavHost(navController, startDestination = Screen.WorkoutListScreen.route) { 42 | // NavGraph 43 | composable(Screen.WorkoutListScreen.route) { 44 | WorkoutListScreen( 45 | ... 46 | ) 47 | } 48 | composable(Screen.WorkoutAddScreen.route) { 49 | WorkoutAddScreen( 50 | ... 51 | ) 52 | } 53 | composable(Screen.WorkoutDetailScreen.route) { 54 | WorkoutDetailScreen( 55 | ... 56 | ) 57 | } 58 | } 59 | ``` 60 | 61 | ## :construction: In development :construction: 62 | The API of compose/compose-navigation is likely to change, since they are still 63 | in alpha. Because of that this project is also subject to change. 64 | 65 | ## License 66 | This project is published under the GPLv3 license. 67 | ``` 68 | Copyright (C) 2020 github.com/p-hlp 69 | 70 | This program is free software: you can redistribute it and/or modify 71 | it under the terms of the GNU General Public License as published by 72 | the Free Software Foundation, either version 3 of the License, or 73 | (at your option) any later version. 74 | This program is distributed in the hope that it will be useful, 75 | but WITHOUT ANY WARRANTY; without even the implied warranty of 76 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 77 | GNU General Public License for more details. 78 | You should have received a copy of the GNU General Public License 79 | along with this program. If not, see . 80 | ``` 81 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | id 'kotlin-kapt' 5 | id 'dagger.hilt.android.plugin' 6 | id 'androidx.navigation.safeargs.kotlin' 7 | } 8 | 9 | android { 10 | compileSdkVersion 30 11 | buildToolsVersion "30.0.2" 12 | 13 | defaultConfig { 14 | applicationId "com.example.intimesimple" 15 | minSdkVersion 24 16 | targetSdkVersion 30 17 | versionCode 1 18 | versionName "1.0" 19 | 20 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 21 | } 22 | 23 | buildTypes { 24 | release { 25 | minifyEnabled false 26 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 27 | } 28 | } 29 | compileOptions { 30 | sourceCompatibility JavaVersion.VERSION_1_8 31 | targetCompatibility JavaVersion.VERSION_1_8 32 | } 33 | kotlinOptions { 34 | jvmTarget = '1.8' 35 | useIR = true 36 | // Treat all Kotlin warnings as errors 37 | //allWarningsAsErrors = true 38 | 39 | freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn' 40 | 41 | // Enable experimental coroutines APIs, including Flow 42 | freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi' 43 | freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.FlowPreview' 44 | freeCompilerArgs += '-Xopt-in=kotlin.Experimental' 45 | 46 | freeCompilerArgs += "-Xallow-jvm-ir-dependencies" 47 | } 48 | 49 | buildFeatures { 50 | compose true 51 | } 52 | 53 | composeOptions { 54 | kotlinCompilerExtensionVersion "1.0.0-alpha08" 55 | kotlinCompilerVersion "1.4.20" 56 | } 57 | 58 | packagingOptions { 59 | exclude "META-INF/AL2.0" 60 | exclude "META-INF/LGPL2.1" 61 | exclude "META-INF/ui-util_release.kotlin_module" 62 | exclude "META-INF/ui-text-android_release.kotlin_module" 63 | exclude "META-INF/ui-graphics_release.kotlin_module" 64 | exclude "META-INF/ui-unit_release.kotlin_module" 65 | exclude "META-INF/ui-geometry_release.kotlin_module" 66 | } 67 | } 68 | 69 | dependencies { 70 | 71 | 72 | kapt deps.room_compiler_kapt 73 | kapt deps.hilt_androidcompiler_kapt 74 | kapt deps.hilt_compiler_kapt 75 | kapt deps.lifecycle_compiler_kapt 76 | 77 | implementation deps.kotlin_stdlib 78 | implementation deps.androidx_core 79 | implementation deps.appcompat 80 | implementation deps.material 81 | implementation deps.constraint_layout 82 | implementation deps.timber 83 | implementation deps.lifecycle_runtime 84 | implementation deps.activity 85 | implementation deps.material_icons 86 | implementation deps.lifecycle_viewmodel 87 | implementation deps.lifecycle_livedata 88 | implementation deps.lifecycle_viewmodel_savedstate 89 | implementation deps.lifecycle_service 90 | implementation deps.room_runtime 91 | implementation deps.room_ktx 92 | implementation deps.coroutines_core 93 | implementation deps.coroutines_android 94 | implementation deps.navigation_fragment 95 | implementation deps.navigation_ui 96 | implementation deps.navigation_dynamic 97 | implementation deps.hilt_android 98 | implementation deps.hilt_lifecycle_viewmodel 99 | implementation deps.legacy_support 100 | implementation deps.preference 101 | implementation deps.compose_compiler 102 | implementation deps.compose_runtime 103 | implementation deps.compose_ui 104 | implementation deps.compose_material 105 | implementation deps.compose_material_icons 106 | implementation deps.compose_ui_tooling 107 | implementation deps.compose_livedata_runtime 108 | implementation deps.compose_animation 109 | implementation deps.compose_foundation 110 | implementation deps.compose_foundation_layout 111 | implementation deps.compose_navigation 112 | implementation deps.compose_savedinstance 113 | implementation deps.datastore_preferences 114 | 115 | testImplementation test_deps.junit 116 | testImplementation test_deps.google_truth 117 | 118 | androidTestImplementation test_deps.androidx_junit 119 | androidTestImplementation test_deps.espresso_core 120 | androidTestImplementation test_deps.navigation_testing 121 | androidTestImplementation test_deps.google_truth 122 | androidTestImplementation test_deps.arch_core_testing 123 | androidTestImplementation test_deps.coroutines_test 124 | } 125 | 126 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { 127 | kotlinOptions { 128 | jvmTarget = "1.8" 129 | freeCompilerArgs += ["-Xallow-jvm-ir-dependencies", "-Xskip-prerelease-check"] 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/com/example/intimesimple/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.example.intimesimple", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/example/intimesimple/LiveDataTestUtil.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple 2 | 3 | import androidx.arch.core.executor.ArchTaskExecutor 4 | import androidx.lifecycle.LiveData 5 | import androidx.lifecycle.Observer 6 | import java.util.concurrent.CountDownLatch 7 | import java.util.concurrent.TimeUnit 8 | object LiveDataTestUtil { 9 | @Throws(InterruptedException::class) 10 | fun awaitValue(liveData: LiveData): T { 11 | val latch = CountDownLatch(1) 12 | var data: T? = null 13 | val observer = object : Observer { 14 | override fun onChanged(o: T?) { 15 | data = o 16 | liveData.removeObserver(this) 17 | latch.countDown() 18 | } 19 | } 20 | ArchTaskExecutor.getMainThreadExecutor().execute { 21 | liveData.observeForever(observer) 22 | } 23 | latch.await(10, TimeUnit.SECONDS) 24 | return data!! 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/example/intimesimple/TestUtil.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple 2 | 3 | import com.example.intimesimple.data.local.Workout 4 | 5 | class TestUtil { 6 | 7 | companion object { 8 | val WORKOUT_1 = Workout( 9 | name = "Stretch", 10 | exerciseTime = 45000L, 11 | pauseTime = 15000L, 12 | repetitions = 10 13 | ) 14 | 15 | val WORKOUT_2 = Workout( 16 | name = "Strength", 17 | exerciseTime = 60000L, 18 | pauseTime = 20000L, 19 | repetitions = 12 20 | ) 21 | 22 | val WORKOUT_3 = Workout( 23 | name = "Abs", 24 | exerciseTime = 30000L, 25 | pauseTime = 15000L, 26 | repetitions = 6 27 | ) 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/example/intimesimple/WorkoutDatabaseTest.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple 2 | 3 | import android.content.Context 4 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 5 | import androidx.room.Room 6 | import androidx.test.core.app.ApplicationProvider 7 | import androidx.test.ext.junit.runners.AndroidJUnit4 8 | import com.example.intimesimple.data.local.AppDatabase 9 | import com.example.intimesimple.data.local.WorkoutDao 10 | import com.google.common.truth.Truth.assertThat 11 | import kotlinx.coroutines.asExecutor 12 | import kotlinx.coroutines.async 13 | import kotlinx.coroutines.cancel 14 | import kotlinx.coroutines.flow.collect 15 | import kotlinx.coroutines.flow.first 16 | import kotlinx.coroutines.flow.take 17 | import kotlinx.coroutines.flow.toList 18 | import kotlinx.coroutines.test.TestCoroutineDispatcher 19 | import kotlinx.coroutines.test.TestCoroutineScope 20 | import kotlinx.coroutines.test.runBlockingTest 21 | import org.junit.After 22 | import org.junit.Before 23 | import org.junit.Rule 24 | import org.junit.Test 25 | import org.junit.runner.RunWith 26 | import java.io.IOException 27 | import java.lang.Exception 28 | import java.util.concurrent.CountDownLatch 29 | 30 | @RunWith(AndroidJUnit4::class) 31 | class WorkoutDatabaseTest { 32 | private lateinit var db: AppDatabase 33 | private lateinit var workoutDao: WorkoutDao 34 | private val testDispatcher = TestCoroutineDispatcher() 35 | private val testScope = TestCoroutineScope(testDispatcher) 36 | 37 | @get:Rule 38 | var instantTaskExecutorRule = InstantTaskExecutorRule() 39 | 40 | @Before 41 | @Throws(IOException::class) 42 | fun createDb(){ 43 | val context = ApplicationProvider.getApplicationContext() 44 | db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) 45 | .setTransactionExecutor(testDispatcher.asExecutor()) 46 | .setQueryExecutor(testDispatcher.asExecutor()) 47 | .allowMainThreadQueries() 48 | .build() 49 | workoutDao = db.workoutDao() 50 | } 51 | 52 | @After 53 | @Throws(Exception::class) 54 | fun closeDb(){ 55 | db.close() 56 | } 57 | 58 | @Test 59 | fun basicFlowTest() = testScope.runBlockingTest { 60 | val workouts = async(testDispatcher) { 61 | workoutDao.getAllWorkouts().collect { 62 | assertThat(it).isEqualTo(listOf(TestUtil.WORKOUT_1, 63 | TestUtil.WORKOUT_2, 64 | TestUtil.WORKOUT_3) 65 | ) 66 | } 67 | } 68 | 69 | workoutDao.insertWorkout(TestUtil.WORKOUT_1) 70 | workoutDao.insertWorkout(TestUtil.WORKOUT_2) 71 | workoutDao.insertWorkout(TestUtil.WORKOUT_3) 72 | } 73 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 23 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/BaseApplication.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | import timber.log.Timber 6 | 7 | @HiltAndroidApp 8 | class BaseApplication: Application() { 9 | override fun onCreate() { 10 | super.onCreate() 11 | Timber.plant(Timber.DebugTree()) 12 | } 13 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple 2 | 3 | import android.content.ComponentName 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.content.ServiceConnection 7 | import androidx.appcompat.app.AppCompatActivity 8 | import android.os.Bundle 9 | import android.os.IBinder 10 | import androidx.activity.viewModels 11 | import androidx.compose.material.ExperimentalMaterialApi 12 | import androidx.compose.ui.platform.setContent 13 | import androidx.navigation.NavHostController 14 | import com.example.intimesimple.utils.Constants.EXTRA_WORKOUT_ID 15 | import androidx.navigation.compose.rememberNavController 16 | import com.example.intimesimple.services.TimerService 17 | import com.example.intimesimple.ui.composables.navigation.AppNavigation 18 | import com.example.intimesimple.ui.theme.INTimeTheme 19 | import com.example.intimesimple.ui.viewmodels.WorkoutDetailViewModel 20 | import com.example.intimesimple.ui.viewmodels.WorkoutListViewModel 21 | import com.example.intimesimple.utils.Constants.ACTION_INITIALIZE_DATA 22 | import dagger.hilt.android.AndroidEntryPoint 23 | import timber.log.Timber 24 | 25 | @AndroidEntryPoint 26 | class MainActivity : AppCompatActivity() { 27 | 28 | private val workoutDetailViewModel: WorkoutDetailViewModel by viewModels() 29 | private val workoutListViewModel: WorkoutListViewModel by viewModels() 30 | private lateinit var navHostController: NavHostController 31 | 32 | private var bound: Boolean = false 33 | 34 | /*This acts as a dummy to trigger onBind/onRebind/onUnbind in TimerService*/ 35 | private val mConnection = object : ServiceConnection { 36 | override fun onServiceConnected(className: ComponentName, service: IBinder) {} 37 | override fun onServiceDisconnected(className: ComponentName) {} 38 | } 39 | 40 | @ExperimentalMaterialApi 41 | override fun onCreate(savedInstanceState: Bundle?) { 42 | super.onCreate(savedInstanceState) 43 | setContent { 44 | INTimeTheme { 45 | navHostController = rememberNavController() 46 | AppNavigation( 47 | navController = navHostController, 48 | workoutListViewModel = workoutListViewModel, 49 | workoutDetailViewModel = workoutDetailViewModel, 50 | sendServiceCommand = ::sendCommandToService 51 | ) 52 | } 53 | } 54 | } 55 | 56 | override fun onStart() { 57 | super.onStart() 58 | Intent(this, TimerService::class.java).also { intent -> 59 | bindService(intent, mConnection, Context.BIND_AUTO_CREATE) 60 | } 61 | bound = true 62 | } 63 | 64 | override fun onStop() { 65 | super.onStop() 66 | // Unbind from the service 67 | if (bound) { 68 | //Timber.d("Trying to unbind service") 69 | unbindService(mConnection) 70 | bound = false 71 | } 72 | } 73 | 74 | /*Navigation from service notification is now handled with compose-navigation deep links 75 | * pendingIntent has a data uri that is the same as destination deeplink in NavigationGraph*/ 76 | override fun onNewIntent(intent: Intent?) { 77 | super.onNewIntent(intent) 78 | } 79 | 80 | override fun onDestroy() { 81 | super.onDestroy() 82 | Timber.d("onDestroy") 83 | } 84 | 85 | private fun sendCommandToService(action: String) { 86 | Intent(this, TimerService::class.java).also { 87 | it.action = action 88 | val id = navHostController.currentBackStackEntry?.arguments?.get("id") as? Long 89 | Timber.d("sendCommandService - Action: $action - ID: $id") 90 | if (action == ACTION_INITIALIZE_DATA) { 91 | it.putExtra(EXTRA_WORKOUT_ID, id) 92 | } 93 | startService(it) 94 | } 95 | } 96 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/data/local/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.data.local 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | 6 | @Database( 7 | entities = [Workout::class], 8 | version = 2, 9 | exportSchema = false 10 | ) 11 | abstract class AppDatabase: RoomDatabase() { 12 | abstract fun workoutDao(): WorkoutDao 13 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/data/local/AudioState.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.data.local 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.filled.Vibration 5 | import androidx.compose.material.icons.filled.VolumeOff 6 | import androidx.compose.material.icons.filled.VolumeUp 7 | import androidx.compose.ui.graphics.vector.ImageVector 8 | 9 | 10 | enum class AudioState{ 11 | MUTE, 12 | VIBRATE, 13 | SOUND 14 | } 15 | 16 | enum class VolumeButtonState(val asset: ImageVector){ 17 | MUTE(Icons.Filled.VolumeOff), 18 | VIBRATE(Icons.Filled.Vibration), 19 | SOUND(Icons.Filled.VolumeUp) 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/data/local/TimerState.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.data.local 2 | 3 | enum class TimerState(val stateName: String) { 4 | RUNNING("RUNNING"), 5 | PAUSED("PAUSED"), 6 | EXPIRED("EXPIRED"); 7 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/data/local/Workout.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.data.local 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | 7 | @Entity(tableName = "workout_table") 8 | data class Workout( 9 | @ColumnInfo(name = "name") var name: String, 10 | @ColumnInfo(name = "exercise_time") var exerciseTime: Long = 45000, // default 45s 11 | @ColumnInfo(name = "pause_time") var pauseTime: Long = 15000L, // default 15s 12 | @ColumnInfo(name = "repetitions") var repetitions: Int, 13 | @ColumnInfo(name = "last_completion") var lastCompletion: Long? = null, // Test values 14 | @ColumnInfo(name = "created_at") var createdAt: Long = 0L 15 | ){ 16 | @PrimaryKey(autoGenerate = true) var id: Long = 0L 17 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/data/local/WorkoutDao.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.data.local 2 | 3 | import androidx.room.* 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.flow.distinctUntilChanged 6 | 7 | @Dao 8 | abstract class WorkoutDao { 9 | @Insert(onConflict = OnConflictStrategy.REPLACE) 10 | abstract suspend fun insertWorkout(workout: Workout): Long 11 | 12 | suspend fun insertWithTimestamp(workout: Workout){ 13 | insertWorkout(workout.apply { 14 | createdAt = System.currentTimeMillis() 15 | }) 16 | } 17 | 18 | @Update 19 | abstract suspend fun updateWorkout(workout: Workout) 20 | 21 | suspend fun updateWithLastCompletion(workout: Workout){ 22 | updateWorkout(workout.apply { 23 | lastCompletion = System.currentTimeMillis() 24 | }) 25 | } 26 | 27 | @Delete 28 | abstract suspend fun deleteWorkout(workout: Workout) 29 | 30 | @Query("DELETE FROM workout_table WHERE id = :wId") 31 | abstract suspend fun deleteWorkoutWithId(wId: Long) 32 | 33 | @Query("SELECT * from workout_table") 34 | abstract fun getAllWorkouts(): Flow> 35 | 36 | @Query("SELECT * FROM workout_table WHERE id = :wId") 37 | abstract fun getWorkoutWithId(wId: Long): Flow 38 | 39 | @Query("SELECT * FROM workout_table WHERE id = :wId") 40 | abstract fun getWorkoutWithIdSingleshot(wId: Long): Workout 41 | 42 | fun getWorkoutDistinctUntilChanged(wId: Long) = 43 | getWorkoutWithId(wId = wId).distinctUntilChanged() 44 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/data/local/WorkoutState.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.data.local 2 | 3 | enum class WorkoutState(val stateName: String){ 4 | WORK("WORK"), 5 | BREAK("BREAK"), 6 | STARTING("STARTING") 7 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.di 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.Preferences 6 | import androidx.datastore.preferences.createDataStore 7 | import androidx.preference.PreferenceDataStore 8 | import androidx.room.Room 9 | import androidx.room.RoomDatabase 10 | import androidx.sqlite.db.SupportSQLiteDatabase 11 | import com.example.intimesimple.data.local.AppDatabase 12 | import com.example.intimesimple.data.local.Workout 13 | import com.example.intimesimple.utils.Constants.DATABASE_NAME 14 | import dagger.Module 15 | import dagger.Provides 16 | import dagger.hilt.InstallIn 17 | import dagger.hilt.android.components.ApplicationComponent 18 | import dagger.hilt.android.components.ServiceComponent 19 | import dagger.hilt.android.qualifiers.ApplicationContext 20 | import kotlinx.coroutines.GlobalScope 21 | import kotlinx.coroutines.launch 22 | import javax.inject.Singleton 23 | 24 | @Module 25 | @InstallIn(ApplicationComponent::class) 26 | object AppModule { 27 | 28 | lateinit var database: AppDatabase 29 | val PREPOPULATE_DATA = listOf( 30 | Workout( "ExampleWorkout1", 35000L, 10000L, 4), 31 | Workout( "ExampleWorkout2", 45000L, 15000L, 3), 32 | Workout( "ExampleWorkout3", 20000L, 5000L, 6) 33 | ) 34 | 35 | @Singleton 36 | @Provides 37 | fun provideAppDatabase( 38 | @ApplicationContext app: Context 39 | ): AppDatabase { 40 | database = Room.databaseBuilder( 41 | app, 42 | AppDatabase::class.java, 43 | DATABASE_NAME 44 | ).addCallback(object: RoomDatabase.Callback(){ 45 | override fun onCreate(db: SupportSQLiteDatabase) { 46 | super.onCreate(db) 47 | GlobalScope.launch { 48 | for (workout in PREPOPULATE_DATA){ 49 | database.workoutDao().insertWithTimestamp(workout) 50 | } 51 | } 52 | } 53 | }).build() 54 | return database 55 | } 56 | 57 | @Singleton 58 | @Provides 59 | fun provideWorkoutDao(db: AppDatabase) = db.workoutDao() 60 | 61 | @Singleton 62 | @Provides 63 | fun provideDataStore( 64 | @ApplicationContext app: Context 65 | ): DataStore = app.createDataStore( 66 | name = "settings" 67 | ) 68 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/di/Qualifiers.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.di 2 | 3 | import javax.inject.Qualifier 4 | 5 | @Qualifier 6 | @Retention(AnnotationRetention.BINARY) 7 | annotation class MainActivityPendingIntent 8 | 9 | @Qualifier 10 | @Retention(AnnotationRetention.BINARY) 11 | annotation class CancelActionPendingIntent 12 | 13 | @Qualifier 14 | @Retention(AnnotationRetention.BINARY) 15 | annotation class PauseActionPendingIntent 16 | 17 | @Qualifier 18 | @Retention(AnnotationRetention.BINARY) 19 | annotation class ResumeActionPendingIntent 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/di/ServiceModule.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.di 2 | 3 | import android.app.NotificationManager 4 | import android.app.PendingIntent 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.os.Vibrator 8 | import androidx.core.app.NotificationCompat 9 | import com.example.intimesimple.MainActivity 10 | import com.example.intimesimple.R 11 | import com.example.intimesimple.data.local.Workout 12 | import com.example.intimesimple.services.TimerService 13 | import com.example.intimesimple.utils.Constants.ACTION_CANCEL 14 | import com.example.intimesimple.utils.Constants.ACTION_PAUSE 15 | import com.example.intimesimple.utils.Constants.ACTION_RESUME 16 | import com.example.intimesimple.utils.Constants.ACTION_SHOW_MAIN_ACTIVITY 17 | import com.example.intimesimple.utils.Constants.NOTIFICATION_CHANNEL_ID 18 | import dagger.Module 19 | import dagger.Provides 20 | import dagger.hilt.InstallIn 21 | import dagger.hilt.android.components.ServiceComponent 22 | import dagger.hilt.android.qualifiers.ApplicationContext 23 | import dagger.hilt.android.scopes.ServiceScoped 24 | 25 | @Module 26 | @InstallIn(ServiceComponent::class) 27 | object ServiceModule { 28 | 29 | @MainActivityPendingIntent 30 | @ServiceScoped 31 | @Provides 32 | fun provideMainActivityPendingIntent( 33 | @ApplicationContext app: Context 34 | ): PendingIntent = PendingIntent.getActivity( 35 | app, 36 | 0, 37 | Intent(app, MainActivity::class.java).also { 38 | it.action = ACTION_SHOW_MAIN_ACTIVITY 39 | }, 40 | PendingIntent.FLAG_UPDATE_CURRENT 41 | ) 42 | 43 | 44 | @CancelActionPendingIntent 45 | @ServiceScoped 46 | @Provides 47 | fun provideCancelActionPendingIntent( 48 | @ApplicationContext app: Context 49 | ): PendingIntent = PendingIntent.getService( 50 | app, 51 | 1, 52 | Intent(app, TimerService::class.java).also { 53 | it.action = ACTION_CANCEL 54 | }, 55 | PendingIntent.FLAG_UPDATE_CURRENT 56 | ) 57 | 58 | 59 | @ResumeActionPendingIntent 60 | @ServiceScoped 61 | @Provides 62 | fun provideResumeActionPendingIntent( 63 | @ApplicationContext app: Context 64 | ): PendingIntent = PendingIntent.getService( 65 | app, 66 | 2, 67 | Intent(app, TimerService::class.java).also { 68 | it.action = ACTION_RESUME 69 | }, 70 | PendingIntent.FLAG_UPDATE_CURRENT 71 | ) 72 | 73 | 74 | @PauseActionPendingIntent 75 | @ServiceScoped 76 | @Provides 77 | fun providePauseActionPendingIntent( 78 | @ApplicationContext app: Context 79 | ): PendingIntent = PendingIntent.getService( 80 | app, 81 | 3, 82 | Intent(app, TimerService::class.java).also { 83 | it.action = ACTION_PAUSE 84 | }, 85 | PendingIntent.FLAG_UPDATE_CURRENT 86 | ) 87 | 88 | 89 | @ServiceScoped 90 | @Provides 91 | fun provideBaseNotificationBuilder( 92 | @ApplicationContext app: Context, 93 | @MainActivityPendingIntent pendingIntent: PendingIntent 94 | ): NotificationCompat.Builder = NotificationCompat.Builder(app, NOTIFICATION_CHANNEL_ID) 95 | .setAutoCancel(false) 96 | .setOngoing(true) 97 | .setSmallIcon(R.drawable.ic_alarm) 98 | .setContentTitle("INTime") 99 | .setContentText("00:00:00") 100 | .setContentIntent(pendingIntent) 101 | 102 | @ServiceScoped 103 | @Provides 104 | fun provideVibrator( 105 | @ApplicationContext app: Context 106 | ): Vibrator = app.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator 107 | 108 | @ServiceScoped 109 | @Provides 110 | fun provideNotificationManager( 111 | @ApplicationContext app: Context 112 | ): NotificationManager = app.getSystemService(Context.NOTIFICATION_SERVICE) 113 | as NotificationManager 114 | 115 | @ServiceScoped 116 | @Provides 117 | fun provideDummyWorkout() = Workout( 118 | name= "", 119 | exerciseTime = -1L, 120 | pauseTime = -1L, 121 | repetitions = -1 122 | ) 123 | } 124 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/repositories/PreferenceRepository.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.repositories 2 | 3 | 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.Preferences 6 | import androidx.datastore.preferences.core.edit 7 | import androidx.datastore.preferences.core.preferencesKey 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.distinctUntilChanged 10 | import kotlinx.coroutines.flow.first 11 | import kotlinx.coroutines.flow.map 12 | import javax.inject.Inject 13 | 14 | class PreferenceRepository @Inject constructor( 15 | private val dataStore: DataStore 16 | ){ 17 | private val SOUND_STATE = preferencesKey("sound_state") 18 | 19 | val soundStateFlow: Flow = dataStore.data 20 | .map { 21 | it[SOUND_STATE] ?: return@map null 22 | }.distinctUntilChanged() 23 | 24 | suspend fun getCurrentSoundState(): String { 25 | return dataStore.data.map { it[SOUND_STATE] ?: "MUTE"}.first() 26 | } 27 | 28 | suspend fun setSoundState(state: String) = dataStore.edit { 29 | it[SOUND_STATE] = state 30 | } 31 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/repositories/WorkoutRepository.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.repositories 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.map 5 | import com.example.intimesimple.data.local.TimerState 6 | import com.example.intimesimple.data.local.Workout 7 | import com.example.intimesimple.data.local.WorkoutDao 8 | import com.example.intimesimple.data.local.WorkoutState 9 | import com.example.intimesimple.services.TimerService 10 | import com.example.intimesimple.utils.Constants 11 | import com.example.intimesimple.utils.getFormattedStopWatchTime 12 | import kotlinx.coroutines.flow.first 13 | import timber.log.Timber 14 | import javax.inject.Inject 15 | 16 | class WorkoutRepository @Inject constructor( 17 | private val workoutDao: WorkoutDao 18 | ){ 19 | // db queries 20 | suspend fun insertWorkout(workout: Workout) = workoutDao.insertWithTimestamp(workout) 21 | suspend fun deleteWorkout(workout: Workout) = workoutDao.deleteWorkout(workout) 22 | suspend fun deleteWorkoutWithId(wId: Long) = workoutDao.deleteWorkoutWithId(wId) 23 | suspend fun updateWorkout(workout: Workout) = workoutDao.updateWorkout(workout) 24 | suspend fun updateWorkoutLastCompletion(workout: Workout) 25 | = workoutDao.updateWithLastCompletion(workout) 26 | fun getAllWorkouts() = workoutDao.getAllWorkouts() 27 | fun getWorkout(wId: Long) = workoutDao.getWorkoutDistinctUntilChanged(wId) 28 | fun getWorkoutSingle(wId: Long) = workoutDao.getWorkoutWithIdSingleshot(wId) 29 | 30 | // return immutable livedata from timer service 31 | fun getTimerServiceWorkoutState() = TimerService.currentWorkoutState as LiveData 32 | fun getTimerServiceTimerState() = TimerService.currentTimerState as LiveData 33 | fun getTimerServiceRepetition() = TimerService.currentRepetition as LiveData 34 | fun getTimerServiceElapsedTimeMillisESeconds () 35 | = TimerService.elapsedTimeInMillisEverySecond as LiveData 36 | fun getTimerServiceElapsedTimeMillis () = TimerService.elapsedTimeInMillis as LiveData 37 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/services/TimerService.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.services 2 | 3 | import android.app.NotificationManager 4 | import android.app.PendingIntent 5 | import android.content.Intent 6 | import android.os.* 7 | import android.speech.tts.TextToSpeech 8 | import androidx.core.app.NotificationCompat 9 | import androidx.lifecycle.LifecycleService 10 | import androidx.lifecycle.MutableLiveData 11 | import androidx.lifecycle.Observer 12 | import com.example.intimesimple.R 13 | import com.example.intimesimple.data.local.AudioState 14 | import com.example.intimesimple.data.local.TimerState 15 | import com.example.intimesimple.data.local.Workout 16 | import com.example.intimesimple.data.local.WorkoutState 17 | import com.example.intimesimple.di.CancelActionPendingIntent 18 | import com.example.intimesimple.di.PauseActionPendingIntent 19 | import com.example.intimesimple.di.ResumeActionPendingIntent 20 | import com.example.intimesimple.repositories.PreferenceRepository 21 | import com.example.intimesimple.repositories.WorkoutRepository 22 | import com.example.intimesimple.utils.* 23 | import com.example.intimesimple.utils.Constants.ACTION_CANCEL 24 | import com.example.intimesimple.utils.Constants.ACTION_CANCEL_AND_RESET 25 | import com.example.intimesimple.utils.Constants.ACTION_INITIALIZE_DATA 26 | import com.example.intimesimple.utils.Constants.ACTION_MUTE 27 | import com.example.intimesimple.utils.Constants.ACTION_PAUSE 28 | import com.example.intimesimple.utils.Constants.ACTION_RESUME 29 | import com.example.intimesimple.utils.Constants.ACTION_SOUND 30 | import com.example.intimesimple.utils.Constants.ACTION_START 31 | import com.example.intimesimple.utils.Constants.ACTION_VIBRATE 32 | import com.example.intimesimple.utils.Constants.EXTRA_WORKOUT_ID 33 | import com.example.intimesimple.utils.Constants.TIMER_STARTING_IN_TIME 34 | import com.example.intimesimple.utils.Constants.TIMER_UPDATE_INTERVAL 35 | import dagger.hilt.android.AndroidEntryPoint 36 | import kotlinx.coroutines.CoroutineScope 37 | import kotlinx.coroutines.Dispatchers 38 | import kotlinx.coroutines.Job 39 | import kotlinx.coroutines.flow.first 40 | import kotlinx.coroutines.launch 41 | import timber.log.Timber 42 | import java.util.* 43 | import javax.inject.Inject 44 | import kotlin.collections.ArrayList 45 | 46 | @AndroidEntryPoint 47 | class TimerService : LifecycleService(), TextToSpeech.OnInitListener{ 48 | 49 | // notification builder 50 | @Inject lateinit var baseNotificationBuilder: NotificationCompat.Builder 51 | lateinit var currentNotificationBuilder: NotificationCompat.Builder 52 | @Inject lateinit var notificationManager: NotificationManager 53 | 54 | // pending intents for notification action-handling 55 | @ResumeActionPendingIntent @Inject lateinit var resumeActionPendingIntent: PendingIntent 56 | @PauseActionPendingIntent @Inject lateinit var pauseActionPendingIntent: PendingIntent 57 | @CancelActionPendingIntent @Inject lateinit var cancelActionPendingIntent: PendingIntent 58 | 59 | // repositories 60 | @Inject lateinit var workoutRepository: WorkoutRepository 61 | @Inject lateinit var preferenceRepository: PreferenceRepository 62 | 63 | // current workout 64 | private var workout: Workout? = null 65 | 66 | // service state 67 | private var isInitialized = false 68 | private var isKilled = true 69 | private var isBound = false 70 | private var audioState = AudioState.MUTE 71 | private var workoutState = WorkoutState.STARTING 72 | 73 | // timer 74 | private var timer: CountDownTimer? = null 75 | private var millisToCompletion = 0L 76 | private var lastSecondTimestamp = 0L 77 | private var timerIndex = 0 78 | private var timerMaxRepetitions = 0 79 | 80 | // audio/tts 81 | @Inject lateinit var vibrator: Vibrator 82 | private var tts: TextToSpeech? = null 83 | 84 | // utility 85 | private val serviceJob = Job() 86 | private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob) 87 | private var wakeLock: PowerManager.WakeLock? = null 88 | @Inject lateinit var dummyWorkout: Workout 89 | 90 | companion object{ 91 | // holds MutableLiveData for UI to observe 92 | val currentTimerState = MutableLiveData() 93 | val currentWorkout = MutableLiveData() 94 | val currentWorkoutState = MutableLiveData() 95 | val currentRepetition = MutableLiveData() 96 | val elapsedTimeInMillis = MutableLiveData() 97 | val elapsedTimeInMillisEverySecond = MutableLiveData() 98 | } 99 | 100 | 101 | override fun onCreate() { 102 | super.onCreate() 103 | Timber.i("onCreate") 104 | // Initialize notificationBuilder & TTS class 105 | currentNotificationBuilder = baseNotificationBuilder 106 | tts = TextToSpeech(this, this) 107 | setupObservers() 108 | } 109 | 110 | override fun onInit(status: Int) { 111 | /* Initialize TTS here */ 112 | Timber.i("onInit") 113 | initializeTTS(status = status) 114 | } 115 | 116 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 117 | super.onStartCommand(intent, flags, startId) 118 | // Handle action from the activity 119 | intent?.let{ 120 | when(it.action){ 121 | // Timer related actions 122 | ACTION_INITIALIZE_DATA -> { 123 | /*Is called when navigating from ListScreen to DetailScreen, fetching data 124 | * from database here -> data initialization*/ 125 | Timber.i("ACTION_INITIALIZE_DATA") 126 | initializeData(it) 127 | } 128 | ACTION_START -> { 129 | /*This is called when Start-Button is pressed, starting timer here and setting*/ 130 | Timber.i("ACTION_START") 131 | startServiceTimer() 132 | } 133 | ACTION_PAUSE -> { 134 | /*Called when pause button is pressed, pause timer, set isTimerRunning = false*/ 135 | Timber.i("ACTION_PAUSE") 136 | pauseTimer() 137 | } 138 | ACTION_RESUME -> { 139 | /*Called when resume button is pressed, resume timer here, set isTimerRunning 140 | * = true*/ 141 | Timber.i("ACTION_RESUME") 142 | resumeTimer() 143 | } 144 | ACTION_CANCEL -> { 145 | /*This is called when cancel button is pressed - resets the current timer to 146 | * start state*/ 147 | Timber.i("ACTION_CANCEL") 148 | cancelServiceTimer() 149 | } 150 | ACTION_CANCEL_AND_RESET -> { 151 | /*Is called when navigating back to ListsScreen, resetting acquired data 152 | * to null*/ 153 | Timber.i("ACTION_CANCEL_AND_RESET") 154 | cancelServiceTimer() 155 | resetData() 156 | } 157 | 158 | // Audio related actions 159 | ACTION_MUTE -> { 160 | /*Sets current audio state to mute*/ 161 | audioState = AudioState.MUTE 162 | } 163 | ACTION_VIBRATE -> { 164 | /*Sets current audio state to vibrate*/ 165 | audioState = AudioState.VIBRATE 166 | } 167 | ACTION_SOUND -> { 168 | /*Sets current audio state to sound enabled*/ 169 | audioState = AudioState.SOUND 170 | } 171 | } 172 | } 173 | return START_STICKY 174 | } 175 | 176 | override fun onBind(intent: Intent): IBinder? { 177 | // UI is visible, use service without being foreground 178 | Timber.i("onBind") 179 | isBound = true 180 | if(!isKilled) pushToBackground() 181 | return super.onBind(intent) 182 | } 183 | 184 | override fun onRebind(intent: Intent?) { 185 | // UI is visible again, push service to background -> notification are not visible 186 | Timber.i("onRebind") 187 | isBound = true 188 | if(!isKilled) pushToBackground() 189 | super.onRebind(intent) 190 | } 191 | 192 | override fun onUnbind(intent: Intent?): Boolean { 193 | // UI is not visible anymore, push service to foreground -> notifications visible 194 | Timber.i("onUnbind") 195 | isBound = false 196 | if(!isKilled) pushToForeground() 197 | // return true so onRebind is used if service is alive and client connects 198 | return true 199 | } 200 | 201 | override fun onDestroy() { 202 | super.onDestroy() 203 | Timber.i("onDestroy") 204 | // cancel coroutine job and TTS 205 | serviceJob.cancel() 206 | tts?.stop() 207 | tts?.shutdown() 208 | } 209 | 210 | private fun startTimer(wasPaused: Boolean = false){ 211 | workout?.let { 212 | 213 | // time to count down 214 | val time = getTimeFromWorkoutState(wasPaused, workoutState, millisToCompletion, it) 215 | Timber.i("Starting timer - time: $time - workoutState: ${workoutState.stateName}") 216 | 217 | // post start values 218 | elapsedTimeInMillisEverySecond.postValue(time) 219 | elapsedTimeInMillis.postValue(time) 220 | lastSecondTimestamp = time 221 | 222 | //initialize timer and start 223 | timer = object : CountDownTimer(time, TIMER_UPDATE_INTERVAL){ 224 | override fun onTick(millisUntilFinished: Long) { 225 | /*handle what happens on every tick with interval of TIMER_UPDATE_INTERVAL*/ 226 | onTimerTick(millisUntilFinished) 227 | } 228 | 229 | override fun onFinish() { 230 | /*handle finishing of a timer 231 | * start new one if there are still repetition left*/ 232 | Timber.i("onFinish") 233 | onTimerFinish() 234 | } 235 | }.start() 236 | 237 | } 238 | } 239 | 240 | private fun onTimerTick(millisUntilFinished: Long){ 241 | millisToCompletion = millisUntilFinished 242 | elapsedTimeInMillis.postValue(millisUntilFinished) 243 | if(millisUntilFinished <= lastSecondTimestamp - 1000L){ 244 | lastSecondTimestamp -= 1000L 245 | //Timber.i("onTick - lastSecondTimestamp: $lastSecondTimestamp") 246 | elapsedTimeInMillisEverySecond.postValue(lastSecondTimestamp) 247 | 248 | // if lastSecondTimestamp within 3 seconds of end, start counting/vibrating 249 | if(lastSecondTimestamp <= 3000L) 250 | speakOrVibrate( 251 | tts = tts, 252 | vibrator = vibrator, 253 | audioState = audioState, 254 | sayText = millisToSeconds(lastSecondTimestamp).toString(), 255 | vibrationLength = 200L 256 | ) 257 | } 258 | } 259 | 260 | private fun onTimerFinish(){ 261 | // increase timerIndex 262 | timerIndex += 1 263 | Timber.i("onTimerFinish - timerIndex: $timerIndex - maxRep: $timerMaxRepetitions") 264 | // check if index still in bound 265 | if(timerIndex < timerMaxRepetitions){ 266 | // if timerIndex odd -> post new rep 267 | if(timerIndex % 2 != 0) 268 | currentRepetition.postValue(currentRepetition.value?.plus(1)) 269 | // get next workout state 270 | workoutState = getNextWorkoutState(workoutState) 271 | currentWorkoutState.postValue(workoutState) 272 | // announce next timer with tts or vibration 273 | speakOrVibrate( 274 | tts = tts, 275 | vibrator = vibrator, 276 | audioState = audioState, 277 | sayText = workoutState.stateName, 278 | vibrationLength = 500L 279 | ) 280 | // start new timer 281 | startTimer() 282 | }else{ 283 | // finished all repetitions, cancel timer 284 | cancelTimer() 285 | } 286 | } 287 | 288 | private fun pauseTimer(){ 289 | currentTimerState.postValue(TimerState.PAUSED) 290 | timer?.cancel() 291 | } 292 | 293 | private fun resumeTimer(){ 294 | currentTimerState.postValue(TimerState.RUNNING) 295 | startTimer(wasPaused = true) 296 | } 297 | 298 | private fun cancelTimer(){ 299 | timer?.cancel() 300 | resetTimer() 301 | } 302 | 303 | private fun resetTimer(){ 304 | timerIndex = 0 305 | timerMaxRepetitions = workout?.repetitions?.times(2)?.minus(2) ?: 0 306 | workoutState = WorkoutState.STARTING 307 | postInitData() 308 | } 309 | 310 | private fun initializeData(intent: Intent){ 311 | if(!isInitialized){ 312 | intent.extras?.let { 313 | val id = it.getLong(EXTRA_WORKOUT_ID) 314 | if(id != -1L){ 315 | // id is valid 316 | currentNotificationBuilder 317 | .setContentIntent(buildMainActivityPendingIntentWithId(id, this)) 318 | 319 | // launch coroutine, fetch workout from db & audiostate from data store 320 | serviceScope.launch { 321 | workout = workoutRepository.getWorkout(id).first() 322 | audioState = AudioState.valueOf(preferenceRepository.getCurrentSoundState()) 323 | isInitialized = true 324 | postInitData() 325 | } 326 | } 327 | } 328 | } 329 | } 330 | 331 | private fun initializeTTS(status: Int){ 332 | tts?.let{ 333 | if (status == TextToSpeech.SUCCESS) { 334 | val result = it.setLanguage(Locale.US) 335 | it.voice = it.defaultVoice 336 | it.language = Locale.US 337 | 338 | if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) { 339 | Timber.d( "The Language specified is not supported!") 340 | } 341 | } else { 342 | Timber.d( "Initialization Failed!") 343 | } 344 | } 345 | } 346 | 347 | private fun postInitData(){ 348 | /*Post current data to MutableLiveData*/ 349 | workout?.let { 350 | currentTimerState.postValue(TimerState.EXPIRED) 351 | currentWorkout.postValue(it) 352 | currentWorkoutState.postValue(WorkoutState.STARTING) 353 | currentRepetition.postValue(1) 354 | elapsedTimeInMillis.postValue(TIMER_STARTING_IN_TIME) 355 | elapsedTimeInMillisEverySecond.postValue(TIMER_STARTING_IN_TIME) 356 | } 357 | } 358 | 359 | private fun resetData(){ 360 | isInitialized = false 361 | workout = null 362 | // set current workout to dummyWorkout 363 | currentWorkout.postValue(dummyWorkout) 364 | // -1 is an invalid value, therefore repString will reset to an empty string 365 | currentRepetition.postValue(-1) 366 | } 367 | 368 | private fun startServiceTimer(){ 369 | // get wakelock 370 | acquireWakelock() 371 | isKilled = false 372 | resetTimer() 373 | startTimer() 374 | currentTimerState.postValue(TimerState.RUNNING) 375 | } 376 | 377 | private fun cancelServiceTimer(){ 378 | releaseWakelock() 379 | cancelTimer() 380 | currentTimerState.postValue(TimerState.EXPIRED) 381 | isKilled = true 382 | stopForeground(true) 383 | } 384 | 385 | private fun acquireWakelock(){ 386 | // acquire a wakelock 387 | wakeLock = (getSystemService(POWER_SERVICE) as PowerManager).run { 388 | newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, 389 | "com.example.intimesimple.services:TimerService::lock").apply { 390 | acquire() 391 | } 392 | } 393 | } 394 | 395 | private fun releaseWakelock(){ 396 | // release wakelock 397 | try { 398 | wakeLock?.let { 399 | if (it.isHeld) { 400 | it.release() 401 | Timber.d("Released wakelock") 402 | } 403 | } 404 | } catch (e: Exception) { 405 | Timber.d("Wasn't able to release wakelock ${e.message}") 406 | } 407 | } 408 | 409 | private fun pushToForeground() { 410 | Timber.i("pushToForeground - isBound: $isBound") 411 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) 412 | createNotificationChannel(notificationManager) 413 | startForeground(Constants.NOTIFICATION_ID, baseNotificationBuilder.build()) 414 | currentTimerState.value?.let { updateNotificationActions(it) } 415 | } 416 | 417 | private fun pushToBackground(){ 418 | Timber.i("pushToBackground - isBound: $isBound") 419 | stopForeground(true) 420 | } 421 | 422 | private fun setupObservers(){ 423 | // observe timerState and update notification actions 424 | currentTimerState.observe(this, Observer { 425 | Timber.i("currentTimerState changed - ${it.stateName}") 426 | if(!isKilled && !isBound) 427 | updateNotificationActions(it) 428 | }) 429 | 430 | // Observe timeInMillis and update notification 431 | elapsedTimeInMillisEverySecond.observe(this, Observer { 432 | if (!isKilled && !isBound) { 433 | // Only do something if timer is running and service in foreground 434 | val notification = currentNotificationBuilder 435 | .setContentText(getFormattedStopWatchTime(it)) 436 | notificationManager.notify(Constants.NOTIFICATION_ID, notification.build()) 437 | } 438 | }) 439 | } 440 | 441 | private fun updateNotificationActions(state: TimerState){ 442 | // Updates actions of current notification depending on TimerState 443 | val notificationActionText = if(state == TimerState.RUNNING) "Pause" else "Resume" 444 | 445 | // Build pendingIntent depending on TimerState 446 | val pendingIntent = if(state == TimerState.RUNNING){ 447 | pauseActionPendingIntent 448 | }else{ 449 | resumeActionPendingIntent 450 | } 451 | 452 | // Clear current actions 453 | currentNotificationBuilder.javaClass.getDeclaredField("mActions").apply { 454 | isAccessible = true 455 | set(currentNotificationBuilder, ArrayList()) 456 | } 457 | 458 | // Set Action, icon seems irrelevant 459 | currentNotificationBuilder = baseNotificationBuilder 460 | .setContentTitle(workout?.name) 461 | .addAction(R.drawable.ic_alarm, notificationActionText, pendingIntent) 462 | .addAction(R.drawable.ic_alarm, "Cancel", cancelActionPendingIntent) 463 | notificationManager.notify(Constants.NOTIFICATION_ID, currentNotificationBuilder.build()) 464 | } 465 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/ui/animations/AnimationDefinitions.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.ui.animations 2 | 3 | import android.annotation.SuppressLint 4 | import androidx.compose.animation.ColorPropKey 5 | import androidx.compose.animation.core.FloatPropKey 6 | import androidx.compose.animation.core.keyframes 7 | import androidx.compose.animation.core.transitionDefinition 8 | import androidx.compose.animation.core.tween 9 | import androidx.compose.ui.graphics.Color 10 | import com.example.intimesimple.ui.theme.DarkBlue900 11 | import com.example.intimesimple.ui.theme.Green500 12 | 13 | 14 | object AnimationDefinitions{ 15 | 16 | val sizeState = FloatPropKey() 17 | val alphaState = FloatPropKey() 18 | val colorState = ColorPropKey() 19 | 20 | enum class FabState{ 21 | Idle, Exploded 22 | } 23 | 24 | 25 | @SuppressLint("Range") 26 | val explodeTransitionDefinition = transitionDefinition{ 27 | state(FabState.Idle){ 28 | this[sizeState] = 60f 29 | this[colorState] = Green500 30 | this[alphaState] = 1f 31 | } 32 | 33 | state(FabState.Exploded){ 34 | this[sizeState] = 4000f 35 | this[colorState] = DarkBlue900 36 | this[alphaState] = 0f 37 | } 38 | 39 | transition(fromState = FabState.Idle, toState = FabState.Exploded){ 40 | sizeState using keyframes { 41 | durationMillis = 700 42 | 60f at 0 43 | 30f at 120 44 | 4000f at 700 45 | } 46 | colorState using tween(durationMillis = 120) 47 | alphaState using tween(durationMillis = 1500) 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/ui/composables/AnimatedDismiss.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.ui.composables 2 | 3 | import androidx.compose.animation.* 4 | import androidx.compose.animation.core.AnimationClockObservable 5 | import androidx.compose.animation.core.tween 6 | import androidx.compose.material.* 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.onCommit 9 | import androidx.compose.runtime.remember 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.platform.AnimationClockAmbient 12 | import timber.log.Timber 13 | 14 | // Credits to https://gist.github.com/bmc08gt 15 | @OptIn(ExperimentalAnimationApi::class, ExperimentalMaterialApi::class) 16 | @Composable 17 | fun AnimatedSwipeDismiss( 18 | modifier: Modifier = Modifier, 19 | item: T, 20 | background: @Composable (isDismissed: Boolean) -> Unit, 21 | content: @Composable (isDismissed: Boolean) -> Unit, 22 | directions: Set = setOf(DismissDirection.StartToEnd), 23 | enter: EnterTransition = expandVertically(), 24 | exit: ExitTransition = shrinkVertically( 25 | animSpec = tween( 26 | durationMillis = 500, 27 | ) 28 | ), 29 | onDismiss: (T) -> Unit 30 | ) { 31 | val dismissState: DismissState = rememberDismissState() 32 | val isDismissed = dismissState.isDismissed(DismissDirection.StartToEnd) 33 | 34 | onCommit(dismissState.value) { 35 | if (dismissState.value == DismissValue.DismissedToEnd) { 36 | onDismiss(item) 37 | Timber.d("isDismissed: $isDismissed") 38 | } 39 | } 40 | 41 | AnimatedVisibility( 42 | modifier = modifier, 43 | visible = !isDismissed, 44 | enter = enter, 45 | exit = exit 46 | ) { 47 | SwipeToDismiss( 48 | modifier = modifier, 49 | state = dismissState, 50 | directions = directions, 51 | background = { background(isDismissed) }, 52 | dismissContent = { content(isDismissed) } 53 | ) 54 | } 55 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/ui/composables/DetailScreenTopBar.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.ui.composables 2 | 3 | import androidx.compose.material.Icon 4 | import androidx.compose.material.IconButton 5 | import androidx.compose.material.Text 6 | import androidx.compose.material.TopAppBar 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.* 9 | import androidx.compose.runtime.getValue 10 | import androidx.compose.runtime.setValue 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.livedata.observeAsState 13 | import androidx.compose.ui.Modifier 14 | import androidx.navigation.NavController 15 | import com.example.intimesimple.ui.viewmodels.WorkoutDetailViewModel 16 | import com.example.intimesimple.utils.Constants.ACTION_CANCEL_AND_RESET 17 | import com.example.intimesimple.utils.getNextVolumeButtonState 18 | import com.example.intimesimple.utils.getTimerActionFromVolumeButtonState 19 | 20 | @Composable 21 | fun DetailScreenTopBar( 22 | modifier: Modifier = Modifier, 23 | title: String, 24 | navController: NavController, 25 | sendCommand: (String) -> Unit, 26 | workoutDetailViewModel: WorkoutDetailViewModel 27 | ){ 28 | val buttonState by workoutDetailViewModel 29 | .volumeButtonState 30 | .observeAsState() 31 | 32 | TopAppBar( 33 | title = { Text(text = title) }, 34 | navigationIcon = { 35 | IconButton( 36 | onClick = { 37 | // navigate back 38 | navController.popBackStack() 39 | // send command cancel 40 | sendCommand(ACTION_CANCEL_AND_RESET) 41 | }, 42 | content = { 43 | Icon(Icons.Filled.ArrowBack) 44 | } 45 | ) 46 | }, 47 | actions = { 48 | IconButton( 49 | onClick = { 50 | buttonState?.let { 51 | val nextState = getNextVolumeButtonState(it) 52 | workoutDetailViewModel.setSoundState(nextState.name) 53 | sendCommand(getTimerActionFromVolumeButtonState(nextState)) 54 | } 55 | 56 | }, 57 | content = { 58 | buttonState?.let { 59 | Icon(it.asset) 60 | } 61 | } 62 | ) 63 | } 64 | ) 65 | } 66 | 67 | 68 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/ui/composables/TimerCircleComponent.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.ui.composables 2 | 3 | 4 | import androidx.compose.foundation.Canvas 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.material.MaterialTheme.typography 7 | import androidx.compose.material.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.remember 10 | import androidx.compose.runtime.getValue 11 | import androidx.compose.runtime.mutableStateOf 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.geometry.Offset 14 | import androidx.compose.ui.geometry.Size 15 | import androidx.compose.ui.graphics.Color 16 | import androidx.compose.ui.graphics.drawscope.Stroke 17 | import androidx.compose.ui.layout.layoutId 18 | import androidx.compose.ui.unit.dp 19 | import com.example.intimesimple.ui.theme.Green500 20 | import com.example.intimesimple.utils.calculateRadiusOffset 21 | import kotlin.math.min 22 | 23 | @Composable 24 | fun TimerCircleComponent( 25 | modifier: Modifier = Modifier, 26 | screenWidthDp: Int, 27 | screenHeightDp: Int, 28 | time: String, 29 | state: String, 30 | reps: String, 31 | elapsedTime: Long, 32 | totalTime: Long 33 | ){ 34 | val maxRadius by remember { mutableStateOf(min(screenHeightDp, screenWidthDp)) } 35 | 36 | Box( 37 | modifier = modifier.size(maxRadius.dp).padding(8.dp) 38 | ){ 39 | 40 | val constraints = if (screenWidthDp.dp < 600.dp) { 41 | portraitConstraints() 42 | } else landscapeConstraints() 43 | ConstraintLayout(modifier = Modifier.fillMaxSize(), constraintSet = constraints) { 44 | 45 | Text( 46 | modifier = Modifier.layoutId("timerText"), 47 | text = time, 48 | style = typography.h2, 49 | ) 50 | 51 | Text( 52 | modifier = Modifier.layoutId("workoutStateText"), 53 | text = state, 54 | style = typography.h5, 55 | ) 56 | 57 | Text( 58 | modifier = Modifier.layoutId("repText"), 59 | text = reps, 60 | style = typography.h5, 61 | ) 62 | } 63 | 64 | // only show in portrait mode 65 | if(screenWidthDp.dp < 600.dp){ 66 | TimerCircle( 67 | modifier = modifier, 68 | elapsedTime = elapsedTime, 69 | totalTime = totalTime 70 | ) 71 | } 72 | } 73 | } 74 | 75 | @Composable 76 | fun TimerCircle( 77 | modifier: Modifier = Modifier, 78 | elapsedTime: Long, 79 | totalTime: Long 80 | ){ 81 | Canvas(modifier = modifier.fillMaxSize(), onDraw = { 82 | val height = size.height 83 | val width = size.width 84 | val dotDiameter = 12.dp 85 | val strokeSize = 20.dp 86 | val radiusOffset 87 | = calculateRadiusOffset(strokeSize.value, dotDiameter.value, 0f) 88 | 89 | val xCenter = width/2f 90 | val yCenter = height/2f 91 | val radius = min(xCenter, yCenter) 92 | val arcWidthHeight = ((radius - radiusOffset) * 2f) 93 | val arcSize = Size(arcWidthHeight, arcWidthHeight) 94 | 95 | val remainderColor = Color.White.copy(alpha = 0.25f) 96 | val completedColor = Green500 97 | 98 | val whitePercent = 99 | min(1f, elapsedTime.toFloat()/totalTime.toFloat()) 100 | val greenPercent = 1 - whitePercent 101 | 102 | drawArc( 103 | completedColor, 104 | 270f, 105 | -greenPercent * 360f, 106 | false, 107 | topLeft = Offset(radiusOffset, radiusOffset), 108 | size = arcSize, 109 | style = Stroke(width = strokeSize.value) 110 | ) 111 | 112 | drawArc( 113 | remainderColor, 114 | 270f, 115 | whitePercent*360, 116 | false, 117 | topLeft = Offset(radiusOffset, radiusOffset), 118 | size = arcSize, 119 | style = Stroke(width = strokeSize.value) 120 | ) 121 | 122 | }) 123 | } 124 | 125 | @Composable 126 | fun DebugCenterLines( 127 | modifier: Modifier 128 | ){ 129 | Canvas(modifier = modifier.fillMaxSize(), onDraw = { 130 | drawLine( 131 | color = Color.Black, 132 | start = Offset(size.width/2f, 0f), 133 | end = Offset(size.width/2f, size.height), 134 | strokeWidth = 4f 135 | ) 136 | 137 | drawLine( 138 | color = Color.Black, 139 | start = Offset(0f, size.height/2f), 140 | end = Offset(size.width, size.height/2f), 141 | strokeWidth = 4f 142 | ) 143 | }) 144 | } 145 | 146 | private fun portraitConstraints(): ConstraintSet { 147 | return ConstraintSet { 148 | val timerText = createRefFor("timerText") 149 | val workoutStateText = createRefFor("workoutStateText") 150 | val repText = createRefFor("repText") 151 | 152 | constrain(timerText) { 153 | centerHorizontallyTo(parent) 154 | centerVerticallyTo(parent) 155 | } 156 | 157 | constrain(workoutStateText) { 158 | centerHorizontallyTo(parent) 159 | bottom.linkTo(timerText.top, 8.dp) 160 | } 161 | 162 | constrain(repText){ 163 | centerHorizontallyTo(parent) 164 | top.linkTo(timerText.bottom, 8.dp) 165 | } 166 | } 167 | } 168 | 169 | private fun landscapeConstraints(): ConstraintSet { 170 | return ConstraintSet { 171 | val timerText = createRefFor("timerText") 172 | val workoutStateText = createRefFor("workoutStateText") 173 | val repText = createRefFor("repText") 174 | 175 | constrain(timerText) { 176 | centerHorizontallyTo(parent) 177 | bottom.linkTo(repText.top, 8.dp) 178 | } 179 | 180 | constrain(workoutStateText) { 181 | centerHorizontallyTo(parent) 182 | bottom.linkTo(timerText.top, 8.dp) 183 | } 184 | 185 | constrain(repText){ 186 | centerHorizontallyTo(parent) 187 | centerVerticallyTo(parent) 188 | } 189 | } 190 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/ui/composables/WorkoutAddScreen.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.ui.composables 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.* 5 | import androidx.compose.material.MaterialTheme.typography 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.filled.Add 8 | import androidx.compose.material.icons.filled.ArrowBack 9 | import androidx.compose.material.icons.filled.Remove 10 | import androidx.compose.runtime.* 11 | import androidx.compose.runtime.getValue 12 | import androidx.compose.runtime.setValue 13 | import androidx.compose.runtime.savedinstancestate.savedInstanceState 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.res.stringResource 17 | import androidx.compose.ui.unit.dp 18 | import androidx.navigation.NavController 19 | import com.example.intimesimple.R 20 | import com.example.intimesimple.ui.viewmodels.WorkoutListViewModel 21 | import com.example.intimesimple.data.local.Workout 22 | import com.example.intimesimple.utils.Constants.ONE_SECOND 23 | import com.example.intimesimple.utils.getFormattedStopWatchTime 24 | import kotlinx.coroutines.launch 25 | 26 | @ExperimentalMaterialApi 27 | @Composable 28 | fun WorkoutAddScreen( 29 | modifier: Modifier = Modifier, 30 | navController: NavController, 31 | workoutListViewModel: WorkoutListViewModel 32 | ){ 33 | val scaffoldState = rememberScaffoldState() 34 | Scaffold( 35 | modifier = modifier, 36 | scaffoldState = scaffoldState, 37 | snackbarHost = { 38 | // reuse default SnackbarHost to have default animation and timing handling 39 | SnackbarHost(it) { data -> 40 | Snackbar( 41 | snackbarData = data, 42 | ) 43 | } 44 | }, 45 | topBar = { 46 | TopAppBar( 47 | title = { 48 | Text( 49 | text = stringResource(id = R.string.workoutAddTitle) 50 | .toUpperCase() 51 | ) 52 | }, 53 | navigationIcon = { 54 | IconButton( 55 | onClick = { 56 | // navigate back 57 | navController.popBackStack() 58 | }, 59 | content = { 60 | Icon(Icons.Filled.ArrowBack) 61 | } 62 | ) 63 | } 64 | ) 65 | }, 66 | bodyContent = { 67 | WorkoutAddScreenContent( 68 | modifier = modifier.padding(it), 69 | navController = navController, 70 | workoutListViewModel = workoutListViewModel, 71 | scaffoldState = scaffoldState 72 | ) 73 | } 74 | ) 75 | } 76 | 77 | @ExperimentalMaterialApi 78 | @Composable 79 | fun WorkoutAddScreenContent( 80 | modifier: Modifier = Modifier, 81 | navController: NavController, 82 | workoutListViewModel: WorkoutListViewModel, 83 | scaffoldState: ScaffoldState 84 | ){ 85 | val scope = rememberCoroutineScope() 86 | val configuration = androidx.compose.ui.platform.ConfigurationAmbient.current 87 | val screenWidth = configuration.screenWidthDp 88 | val buttonWidth = 0.3f * screenWidth 89 | val rowWidth = .75f * screenWidth 90 | 91 | ConstraintLayout( 92 | modifier = modifier 93 | ) { 94 | val (buttonRow, textFieldRow, workRow, pauseRow, repsRow) = createRefs() 95 | var nameField by savedInstanceState {""} 96 | var workField by savedInstanceState {30000L} 97 | var pauseField by savedInstanceState {10000L} 98 | var repsField by savedInstanceState {1} 99 | var errorState by remember { mutableStateOf(false) } 100 | val invalidInput = nameField.isBlank() 101 | 102 | OutlinedTextField( 103 | modifier = Modifier.width(rowWidth.dp).constrainAs(textFieldRow) { 104 | top.linkTo(parent.top, 32.dp) 105 | centerHorizontallyTo(parent) 106 | }, 107 | value = nameField, 108 | onValueChange = { 109 | if(!it.isBlank()){ 110 | errorState = false 111 | } 112 | nameField = it 113 | }, 114 | label = { Text("Name") }, 115 | placeholder = { Text("Enter name here") }, 116 | textStyle = typography.body1, 117 | isErrorValue = errorState 118 | ) 119 | 120 | clickInputField( 121 | modifier = Modifier.width(rowWidth.dp).constrainAs(workRow){ 122 | top.linkTo(textFieldRow.bottom, 16.dp) 123 | centerHorizontallyTo(parent) 124 | }, 125 | label = "Work", 126 | content = { 127 | Text(getFormattedStopWatchTime(workField),style = typography.h3) 128 | }, 129 | onMinusClicked = { 130 | if(workField >= 2000L){ 131 | workField -= ONE_SECOND 132 | } 133 | }, 134 | onPlusClicked = { 135 | workField += ONE_SECOND 136 | } 137 | ) 138 | 139 | clickInputField( 140 | modifier = Modifier.width(rowWidth.dp).constrainAs(pauseRow){ 141 | top.linkTo(workRow.bottom, 16.dp) 142 | centerHorizontallyTo(parent) 143 | }, 144 | label = "Pause", 145 | content = { 146 | Text(getFormattedStopWatchTime(pauseField),style = typography.h3) 147 | }, 148 | onMinusClicked = { 149 | if(pauseField > ONE_SECOND){ 150 | pauseField -= ONE_SECOND 151 | } 152 | }, 153 | onPlusClicked = { 154 | pauseField += ONE_SECOND 155 | } 156 | ) 157 | 158 | clickInputField( 159 | modifier = Modifier.width(rowWidth.dp).constrainAs(repsRow){ 160 | top.linkTo(pauseRow.bottom, 16.dp) 161 | centerHorizontallyTo(parent) 162 | }, 163 | label = "Reps", 164 | content = { 165 | Text(repsField.toString(),style = typography.h3) 166 | }, 167 | onMinusClicked = { 168 | if(repsField > 1){ 169 | repsField -= 1 170 | } 171 | }, 172 | onPlusClicked = { 173 | repsField += 1 174 | } 175 | ) 176 | Row( 177 | modifier = Modifier.constrainAs(buttonRow){ 178 | centerHorizontallyTo(parent) 179 | bottom.linkTo(parent.bottom, 32.dp) 180 | }.fillMaxWidth(), 181 | horizontalArrangement = Arrangement.SpaceEvenly, 182 | ) { 183 | Button( 184 | modifier = Modifier.width(buttonWidth.dp), 185 | onClick = { 186 | if (!invalidInput) { 187 | // build workout from inputs 188 | val workout = Workout( 189 | name = nameField, 190 | exerciseTime = workField, 191 | pauseTime = pauseField, 192 | repetitions = repsField 193 | ) 194 | // insert into db 195 | workoutListViewModel.addWorkout(workout) 196 | // Pop backstack -> back to list screen 197 | navController.popBackStack() 198 | } else { 199 | errorState = true 200 | scope.launch { 201 | scaffoldState.snackbarHostState.showSnackbar( 202 | "Name-Field is empty, please enter a valid name." 203 | ) 204 | } 205 | } 206 | }, 207 | shape = MaterialTheme.shapes.medium 208 | ) { 209 | Text(text = "Add") 210 | } 211 | Button( 212 | modifier = Modifier.width(buttonWidth.dp), 213 | onClick = { 214 | // Pop backstack -> back to list screen 215 | navController.popBackStack() 216 | }, 217 | shape = MaterialTheme.shapes.medium 218 | ) { 219 | Text(text = "Cancel") 220 | } 221 | 222 | } 223 | } 224 | } 225 | 226 | 227 | @Composable 228 | fun clickInputField( 229 | modifier: Modifier = Modifier, 230 | label: String, 231 | content: @Composable () -> Unit, 232 | onMinusClicked: () -> Unit, 233 | onPlusClicked: () -> Unit 234 | ){ 235 | val emphasisLevels = AmbientEmphasisLevels.current 236 | 237 | Column( 238 | modifier 239 | ){ 240 | 241 | ProvideEmphasis(emphasisLevels.disabled){ 242 | Text( 243 | text = label, 244 | modifier = Modifier.align(Alignment.CenterHorizontally).padding(bottom = 2.dp), 245 | style = typography.subtitle2 246 | ) 247 | } 248 | 249 | Card( 250 | modifier = Modifier.height(50.dp), 251 | elevation = 1.dp, 252 | ){ 253 | ConstraintLayout(modifier = Modifier.fillMaxSize()) { 254 | val (minusFab, plusFab, textBox) = createRefs() 255 | 256 | IconButton( 257 | modifier = Modifier.constrainAs(minusFab) { 258 | start.linkTo(parent.start) 259 | centerVerticallyTo(parent) 260 | }, 261 | onClick = {onMinusClicked()}, 262 | content = {Icon(Icons.Filled.Remove)} 263 | ) 264 | 265 | Box( 266 | modifier = Modifier.constrainAs(textBox){ 267 | centerVerticallyTo(parent) 268 | centerHorizontallyTo(parent) 269 | } 270 | ){ 271 | content() 272 | } 273 | 274 | IconButton( 275 | modifier = Modifier. constrainAs(plusFab){ 276 | end.linkTo(parent.end) 277 | centerVerticallyTo(parent) 278 | }, 279 | onClick = {onPlusClicked()}, 280 | content = {Icon(Icons.Filled.Add)} 281 | ) 282 | } 283 | } 284 | } 285 | 286 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/ui/composables/WorkoutDetailScreen.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.ui.composables 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.* 5 | import androidx.compose.runtime.* 6 | import androidx.compose.runtime.livedata.observeAsState 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.runtime.setValue 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.layout.WithConstraints 11 | import androidx.compose.ui.unit.dp 12 | import com.example.intimesimple.data.local.TimerState 13 | import com.example.intimesimple.utils.Constants 14 | import androidx.compose.ui.layout.layoutId 15 | import androidx.compose.ui.platform.ConfigurationAmbient 16 | import androidx.compose.ui.unit.Dp 17 | import androidx.navigation.NavController 18 | import com.example.intimesimple.data.local.Workout 19 | import com.example.intimesimple.data.local.WorkoutState 20 | import com.example.intimesimple.ui.viewmodels.WorkoutDetailViewModel 21 | import com.example.intimesimple.utils.Constants.TIMER_STARTING_IN_TIME 22 | import timber.log.Timber 23 | 24 | 25 | @Composable 26 | fun WorkoutDetailScreen( 27 | modifier: Modifier = Modifier, 28 | navController: NavController, 29 | workoutDetailViewModel: WorkoutDetailViewModel, 30 | sendServiceCommand: (String) -> Unit, 31 | workoutId: Long? = null 32 | ) { 33 | val workout by workoutDetailViewModel.workout.observeAsState() 34 | 35 | Timber.d("WorkoutId: $workoutId - Workout: ${workout?.name ?: "null"}") 36 | Scaffold( 37 | modifier.fillMaxSize(), 38 | topBar = { 39 | DetailScreenTopBar( 40 | title = workout?.name ?: "", 41 | navController = navController, 42 | sendCommand = sendServiceCommand, 43 | workoutDetailViewModel = workoutDetailViewModel 44 | ) 45 | }, 46 | bodyContent = { paddingValues -> 47 | WorkoutDetailScreenContent( 48 | modifier = modifier.padding(paddingValues), 49 | sendCommand = sendServiceCommand, 50 | workoutDetailViewModel = workoutDetailViewModel 51 | ) 52 | } 53 | ) 54 | } 55 | 56 | 57 | @Composable 58 | fun WorkoutDetailScreenContent( 59 | modifier: Modifier = Modifier, 60 | sendCommand: (String) -> Unit, 61 | workoutDetailViewModel: WorkoutDetailViewModel 62 | ) { 63 | val timerState by workoutDetailViewModel.timerState.observeAsState(TimerState.EXPIRED) 64 | val timeString by workoutDetailViewModel.timeString.observeAsState("") 65 | val workoutState by workoutDetailViewModel.workoutState.observeAsState(WorkoutState.STARTING) 66 | val repsString by workoutDetailViewModel.repString.observeAsState("") 67 | val elapsedTime by workoutDetailViewModel.elapsedTime.observeAsState(TIMER_STARTING_IN_TIME) 68 | val totalTime by workoutDetailViewModel.totalTime.observeAsState(TIMER_STARTING_IN_TIME) 69 | 70 | val configuration = ConfigurationAmbient.current 71 | val screenWidthDp = configuration.screenWidthDp 72 | val screenHeightDp = configuration.screenHeightDp 73 | val buttonWidth = 0.3f * screenWidthDp 74 | 75 | WithConstraints(modifier) { 76 | 77 | val constraints = if (screenWidthDp.dp < 600.dp) { 78 | portraitConstraints() 79 | } else landscapeConstraints() 80 | 81 | ConstraintLayout(modifier = modifier, constraintSet = constraints) { 82 | 83 | TimerCircleComponent( 84 | modifier = Modifier.layoutId("progCircle"), 85 | screenWidthDp = screenWidthDp, 86 | screenHeightDp = screenHeightDp, 87 | time = timeString, 88 | state = workoutState.stateName, 89 | reps = repsString, 90 | elapsedTime = elapsedTime, 91 | totalTime = totalTime 92 | ) 93 | 94 | ButtonRow( 95 | modifier = Modifier.layoutId("buttonRow"), 96 | state = timerState, 97 | buttonWidth = buttonWidth.dp, 98 | sendCommand = sendCommand 99 | ) 100 | } 101 | } 102 | } 103 | 104 | @Composable 105 | fun ButtonRow( 106 | modifier: Modifier = Modifier, 107 | state: TimerState, 108 | buttonWidth: Dp, 109 | sendCommand: (String) -> Unit 110 | ){ 111 | val buttonModifier = Modifier.width(buttonWidth) 112 | Row( 113 | modifier.fillMaxWidth(), 114 | horizontalArrangement = Arrangement.SpaceEvenly 115 | ){ 116 | when (state) { 117 | TimerState.EXPIRED -> { 118 | Button( 119 | onClick = { 120 | sendCommand(Constants.ACTION_START) 121 | }, 122 | shape = MaterialTheme.shapes.medium, 123 | modifier = buttonModifier 124 | ) { 125 | Text("Start") 126 | } 127 | } 128 | TimerState.RUNNING -> { 129 | Button( 130 | onClick = { sendCommand(Constants.ACTION_PAUSE) }, 131 | shape = MaterialTheme.shapes.medium, 132 | modifier = buttonModifier 133 | ) { 134 | Text("Pause") 135 | } 136 | 137 | Button( 138 | onClick = { sendCommand(Constants.ACTION_CANCEL) }, 139 | shape = MaterialTheme.shapes.medium, 140 | modifier = buttonModifier 141 | ) { 142 | Text("Cancel") 143 | } 144 | } 145 | TimerState.PAUSED -> { 146 | Button( 147 | onClick = { sendCommand(Constants.ACTION_RESUME) }, 148 | shape = MaterialTheme.shapes.medium, 149 | modifier = buttonModifier 150 | ) { 151 | Text("Resume") 152 | } 153 | 154 | Button( 155 | onClick = { sendCommand(Constants.ACTION_CANCEL) }, 156 | shape = MaterialTheme.shapes.medium, 157 | modifier = buttonModifier 158 | ) { 159 | Text("Cancel") 160 | } 161 | } 162 | } 163 | } 164 | } 165 | 166 | private fun portraitConstraints(): ConstraintSet { 167 | return ConstraintSet { 168 | val buttonRow = createRefFor("buttonRow") 169 | val progCircle = createRefFor("progCircle") 170 | 171 | constrain(progCircle) { 172 | top.linkTo(parent.top) 173 | centerHorizontallyTo(parent) 174 | } 175 | 176 | constrain(buttonRow) { 177 | bottom.linkTo(parent.bottom, 64.dp) 178 | } 179 | } 180 | } 181 | 182 | private fun landscapeConstraints(): ConstraintSet { 183 | return ConstraintSet { 184 | val buttonRow = createRefFor("buttonRow") 185 | val progCircle = createRefFor("progCircle") 186 | 187 | constrain(buttonRow) { 188 | bottom.linkTo(parent.bottom, 32.dp) 189 | } 190 | 191 | constrain(progCircle) { 192 | top.linkTo(parent.top) 193 | centerHorizontallyTo(parent) 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/ui/composables/WorkoutItem.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.ui.composables 2 | 3 | import androidx.compose.animation.animate 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.material.* 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.Delete 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.unit.dp 14 | import com.example.intimesimple.data.local.Workout 15 | import com.example.intimesimple.ui.theme.Green500 16 | import com.example.intimesimple.utils.getCompletionTimeForWorkout 17 | import com.example.intimesimple.utils.getFormattedCompletionTime 18 | 19 | @Composable 20 | fun WorkoutItem( 21 | modifier: Modifier = Modifier, 22 | workout: Workout, 23 | onClick: (Workout) -> Unit 24 | ){ 25 | Card( 26 | modifier = modifier 27 | .padding(horizontal = 16.dp, vertical = 8.dp) 28 | .clickable(onClick = {onClick(workout)}) 29 | ) { 30 | Row( 31 | modifier = modifier 32 | .fillMaxWidth() 33 | .padding(8.dp), 34 | horizontalArrangement = Arrangement.Start, 35 | verticalAlignment = Alignment.CenterVertically 36 | ) { 37 | Column( 38 | Modifier.weight(1f) 39 | ){ 40 | WorkoutCardInfoColumn(workout) 41 | } 42 | Column( 43 | Modifier.weight(1f), 44 | horizontalAlignment = Alignment.CenterHorizontally 45 | ){ 46 | WorkoutCardTimeColumn(workout) 47 | } 48 | } 49 | } 50 | } 51 | 52 | @Composable 53 | fun WorkoutItemDismissBackground(isDismissed: Boolean){ 54 | Surface( 55 | modifier = Modifier 56 | .padding(horizontal = 16.dp, vertical = 8.dp) 57 | .fillMaxSize(), 58 | shape = MaterialTheme.shapes.medium, 59 | color = Green500.copy(alpha = 0.75f) 60 | ){ 61 | ConstraintLayout(modifier = Modifier.fillMaxSize()) { 62 | val boxRef = createRef() 63 | Box( 64 | modifier = Modifier.constrainAs(boxRef) { 65 | centerVerticallyTo(parent) 66 | start.linkTo(parent.start) }.padding(horizontal = 10.dp) 67 | ){ 68 | val alpha = animate( if (isDismissed) 0f else 1f) 69 | Icon(Icons.Filled.Delete, tint = Color.White.copy(alpha = alpha)) 70 | } 71 | } 72 | } 73 | } 74 | 75 | @Composable 76 | fun WorkoutCardInfoColumn(workout: Workout){ 77 | // Name, last completion 78 | val emphasisLevels = AmbientEmphasisLevels.current 79 | Text(workout.name, style = MaterialTheme.typography.h3, maxLines = 2) 80 | 81 | Spacer(modifier = Modifier.padding(4.dp)) 82 | 83 | ProvideEmphasis(emphasisLevels.medium) { 84 | Text("${workout.repetitions} Repetition${if(workout.repetitions > 1) "s" else ""}", style = MaterialTheme.typography.body2) 85 | 86 | Text("${getFormattedCompletionTime(workout.exerciseTime)} Work", style = MaterialTheme.typography.body2) 87 | 88 | Text("${getFormattedCompletionTime(workout.pauseTime)} Pause", style = MaterialTheme.typography.body2) 89 | } 90 | } 91 | 92 | 93 | @Composable 94 | fun WorkoutCardTimeColumn(workout: Workout){ 95 | Surface( 96 | color = Green500.copy(alpha = 0.75f), 97 | shape = MaterialTheme.shapes.medium 98 | ){ 99 | Text(text = getCompletionTimeForWorkout(workout), 100 | style = MaterialTheme.typography.h3, 101 | modifier = Modifier.padding(8.dp) 102 | ) 103 | } 104 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/ui/composables/WorkoutListScreen.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.ui.composables 2 | 3 | import android.net.Uri 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.lazy.LazyColumnFor 6 | import androidx.compose.material.* 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.Add 9 | import androidx.compose.runtime.* 10 | import androidx.compose.runtime.livedata.observeAsState 11 | import androidx.compose.runtime.getValue 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.res.stringResource 14 | import androidx.compose.ui.unit.dp 15 | import androidx.navigation.compose.* 16 | import androidx.navigation.NavController 17 | import com.example.intimesimple.R 18 | import com.example.intimesimple.data.local.Workout 19 | import com.example.intimesimple.ui.composables.navigation.Screen 20 | import com.example.intimesimple.ui.theme.Green500 21 | import com.example.intimesimple.ui.viewmodels.WorkoutListViewModel 22 | import com.example.intimesimple.utils.Constants.ACTION_INITIALIZE_DATA 23 | import com.example.intimesimple.utils.Constants.WORKOUT_DETAIL_URI 24 | 25 | 26 | 27 | @ExperimentalMaterialApi 28 | @Composable 29 | fun WorkoutListScreen( 30 | modifier: Modifier = Modifier, 31 | navController: NavController, 32 | workoutListViewModel: WorkoutListViewModel, 33 | sendServiceCommand: (String) -> Unit 34 | ){ 35 | // get workout list as observable state 36 | val workouts by workoutListViewModel.workouts.observeAsState(listOf()) 37 | var animateFab by remember { mutableStateOf(false) } 38 | 39 | // build screen layout with scaffold 40 | Scaffold( 41 | modifier = modifier, 42 | topBar = { 43 | TopAppBar( 44 | title = { 45 | Text( 46 | text = stringResource(id = R.string.app_name).toUpperCase() 47 | ) 48 | } 49 | ) 50 | }, 51 | bodyContent = { paddingValues -> 52 | WorkoutListContent( 53 | modifier = modifier.padding(paddingValues), 54 | innerPadding = PaddingValues(4.dp), 55 | items = workouts, 56 | onSwipe = { 57 | workoutListViewModel.deleteWorkout(it) 58 | }, 59 | onClick = { 60 | navController.navigate( 61 | Uri.parse(WORKOUT_DETAIL_URI + "${it.id}") 62 | ) 63 | sendServiceCommand(ACTION_INITIALIZE_DATA) 64 | } 65 | ) 66 | }, 67 | floatingActionButton = { 68 | FloatingActionButton( 69 | onClick = { 70 | navController.navigate(Screen.WorkoutAddScreen.route) 71 | }, 72 | content = { 73 | Icon(Icons.Filled.Add) 74 | }, 75 | backgroundColor = Green500, 76 | ) 77 | }, 78 | floatingActionButtonPosition = FabPosition.End 79 | ) 80 | } 81 | 82 | @ExperimentalMaterialApi 83 | @Composable 84 | fun WorkoutListContent( 85 | modifier: Modifier = Modifier, 86 | innerPadding: PaddingValues, 87 | items: List, 88 | onSwipe: (Workout) -> Unit, 89 | onClick: (Workout) -> Unit 90 | ) { 91 | LazyColumnFor( 92 | modifier = modifier.padding(innerPadding), 93 | items = items, 94 | ) { item -> 95 | // https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary#key 96 | key(item.id){ 97 | val dismissState = rememberDismissState() 98 | var isDismissed by remember { mutableStateOf(false) } 99 | //Timber.d("DismissState: ${dismissState.value}") 100 | onCommit(dismissState.value){ 101 | if(dismissState.value == DismissValue.DismissedToEnd ){ 102 | //Timber.d("onSwipe() - Dismissing WorkoutID: ${item.id}") 103 | isDismissed = true 104 | onSwipe(item) 105 | } 106 | } 107 | 108 | // Fixed with alpha05 109 | SwipeToDismiss( 110 | modifier = modifier, 111 | state = dismissState, 112 | directions = setOf(DismissDirection.StartToEnd), 113 | background = { 114 | WorkoutItemDismissBackground(isDismissed) 115 | } 116 | ){ 117 | WorkoutItem( 118 | workout = item, 119 | onClick = onClick 120 | ) 121 | } 122 | } 123 | } 124 | } 125 | 126 | @Composable 127 | fun WorkoutListAnimatedContent( 128 | modifier: Modifier = Modifier, 129 | innerPadding: PaddingValues, 130 | items: List, 131 | onSwipe: (Workout) -> Unit, 132 | onClick: (Workout) -> Unit 133 | ){ 134 | LazyColumnFor( 135 | modifier = modifier.padding(innerPadding), 136 | items = items, 137 | ) { item -> 138 | // https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary#key 139 | key(item.id){ 140 | AnimatedSwipeDismiss( 141 | item = item, 142 | background = { 143 | //WorkoutItemDismissBackground() 144 | }, 145 | content = { 146 | WorkoutItem( 147 | workout = item, 148 | onClick = onClick 149 | ) 150 | }, 151 | onDismiss = { 152 | onSwipe(item) 153 | } 154 | ) 155 | } 156 | } 157 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/ui/composables/navigation/Navigation.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.ui.composables.navigation 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.ExperimentalMaterialApi 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.navigation.NavHostController 8 | import androidx.navigation.compose.NavHost 9 | import androidx.navigation.compose.composable 10 | import androidx.navigation.compose.* 11 | import androidx.navigation.navDeepLink 12 | import com.example.intimesimple.ui.composables.WorkoutAddScreen 13 | import com.example.intimesimple.ui.composables.WorkoutDetailScreen 14 | import com.example.intimesimple.ui.composables.WorkoutListScreen 15 | import com.example.intimesimple.ui.viewmodels.WorkoutDetailViewModel 16 | import com.example.intimesimple.ui.viewmodels.WorkoutListViewModel 17 | import com.example.intimesimple.utils.Constants.WORKOUT_DETAIL_URI 18 | 19 | @ExperimentalMaterialApi 20 | @Composable 21 | fun AppNavigation( 22 | navController: NavHostController, 23 | workoutListViewModel: WorkoutListViewModel, 24 | workoutDetailViewModel: WorkoutDetailViewModel, 25 | sendServiceCommand: (String) -> Unit 26 | ) { 27 | NavHost(navController, startDestination = Screen.WorkoutListScreen.route) { 28 | // NavGraph 29 | composable(Screen.WorkoutListScreen.route) { 30 | WorkoutListScreen( 31 | modifier = Modifier.fillMaxSize(), 32 | navController = navController, 33 | workoutListViewModel = workoutListViewModel, 34 | sendServiceCommand = sendServiceCommand 35 | ) 36 | } 37 | composable(Screen.WorkoutAddScreen.route) { 38 | WorkoutAddScreen( 39 | modifier = Modifier.fillMaxSize(), 40 | navController = navController, 41 | workoutListViewModel = workoutListViewModel 42 | ) 43 | } 44 | composable( 45 | Screen.WorkoutDetailScreen.route, 46 | arguments = listOf(navArgument("id"){ defaultValue = -1L}), 47 | deepLinks = listOf(navDeepLink { uriPattern = "$WORKOUT_DETAIL_URI{id}"}) 48 | ) { 49 | WorkoutDetailScreen( 50 | modifier = Modifier.fillMaxSize(), 51 | navController = navController, 52 | workoutDetailViewModel = workoutDetailViewModel, 53 | sendServiceCommand = sendServiceCommand, 54 | workoutId = it.arguments?.get("id") as? Long 55 | ) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/ui/composables/navigation/Screens.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.ui.composables.navigation 2 | 3 | import androidx.annotation.StringRes 4 | import com.example.intimesimple.R 5 | 6 | sealed class Screen(val route: String, @StringRes val resourceId: Int){ 7 | object WorkoutListScreen: Screen("workoutlist", R.string.workoutlist) 8 | object WorkoutAddScreen: Screen("workoutadd", R.string.workoutadd) 9 | object WorkoutDetailScreen: Screen("workoutdetail", R.string.workoutdetail) 10 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val purple200 = Color(0xFFBB86FC) 6 | val purple500 = Color(0xFF6200EE) 7 | val purple700 = Color(0xFF3700B3) 8 | val teal200 = Color(0xFF03DAC5) 9 | val Green500 = Color(0xFF1EB980) 10 | val DarkBlue900 = Color(0xFF26282F) -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val shapes = Shapes( 8 | small = RoundedCornerShape(4.dp), 9 | medium = RoundedCornerShape(4.dp), 10 | large = RoundedCornerShape(0.dp) 11 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.ui.theme 2 | 3 | import androidx.compose.material.MaterialTheme 4 | import androidx.compose.material.Typography 5 | import androidx.compose.material.darkColors 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.graphics.Color 8 | import androidx.compose.ui.graphics.compositeOver 9 | import androidx.compose.ui.text.TextStyle 10 | import androidx.compose.ui.text.font.FontWeight 11 | import androidx.compose.ui.text.font.font 12 | import androidx.compose.ui.text.font.fontFamily 13 | import androidx.compose.ui.unit.em 14 | import androidx.compose.ui.unit.sp 15 | import com.example.intimesimple.R 16 | 17 | private val DarkColorPalette = darkColors( 18 | primary = Green500, 19 | surface = DarkBlue900, 20 | onSurface = Color.White, 21 | background = DarkBlue900, 22 | onBackground = Color.White 23 | ) 24 | 25 | 26 | private val RobotoCondensed = fontFamily( 27 | font(R.font.robotocondensed_regular), 28 | font(R.font.robotocondensed_light, FontWeight.Light), 29 | font(R.font.robotocondensed_bold, FontWeight.Bold) 30 | ) 31 | 32 | 33 | @Composable 34 | fun INTimeTheme(content: @Composable() () -> Unit) { 35 | val typography = Typography( 36 | defaultFontFamily = RobotoCondensed, 37 | h1 = TextStyle( 38 | fontWeight = FontWeight.W100, 39 | fontSize = 96.sp, 40 | ), 41 | h2 = TextStyle( 42 | fontWeight = FontWeight.SemiBold, 43 | fontSize = 44.sp, 44 | letterSpacing = 1.5.sp 45 | ), 46 | h3 = TextStyle( 47 | fontWeight = FontWeight.W400, 48 | fontSize = 18.sp, 49 | letterSpacing = 1.sp 50 | ), 51 | h4 = TextStyle( 52 | fontWeight = FontWeight.W700, 53 | fontSize = 34.sp 54 | ), 55 | h5 = TextStyle( 56 | fontWeight = FontWeight.W700, 57 | fontSize = 24.sp 58 | ), 59 | h6 = TextStyle( 60 | fontWeight = FontWeight.Normal, 61 | fontSize = 18.sp, 62 | lineHeight = 20.sp, 63 | letterSpacing = 3.sp 64 | ), 65 | subtitle1 = TextStyle( 66 | fontWeight = FontWeight.Light, 67 | fontSize = 14.sp, 68 | lineHeight = 20.sp, 69 | letterSpacing = 3.sp 70 | ), 71 | subtitle2 = TextStyle( 72 | fontWeight = FontWeight.W500, 73 | fontSize = 16.sp, 74 | letterSpacing = 0.1.em 75 | ), 76 | body1 = TextStyle( 77 | fontWeight = FontWeight.Normal, 78 | fontSize = 16.sp, 79 | letterSpacing = 0.1.em 80 | ), 81 | body2 = TextStyle( 82 | fontWeight = FontWeight.Normal, 83 | fontSize = 14.sp, 84 | lineHeight = 20.sp, 85 | letterSpacing = 0.1.em 86 | ), 87 | button = TextStyle( 88 | fontWeight = FontWeight.Bold, 89 | fontSize = 16.sp, 90 | lineHeight = 18.sp, 91 | letterSpacing = 0.2.em 92 | ), 93 | caption = TextStyle( 94 | fontWeight = FontWeight.W500, 95 | fontSize = 12.sp 96 | ), 97 | overline = TextStyle( 98 | fontWeight = FontWeight.W500, 99 | fontSize = 10.sp 100 | ) 101 | ) 102 | 103 | MaterialTheme(colors = DarkColorPalette, typography = typography, shapes = shapes, content = content) 104 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/ui/viewmodels/WorkoutDetailViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.ui.viewmodels 2 | 3 | 4 | import androidx.hilt.lifecycle.ViewModelInject 5 | import androidx.lifecycle.* 6 | import com.example.intimesimple.data.local.TimerState 7 | import com.example.intimesimple.data.local.VolumeButtonState 8 | import com.example.intimesimple.data.local.Workout 9 | import com.example.intimesimple.data.local.WorkoutState 10 | import com.example.intimesimple.repositories.PreferenceRepository 11 | import com.example.intimesimple.repositories.WorkoutRepository 12 | import com.example.intimesimple.services.TimerService 13 | import com.example.intimesimple.utils.Constants.TIMER_STARTING_IN_TIME 14 | import com.example.intimesimple.utils.getFormattedStopWatchTime 15 | import kotlinx.coroutines.launch 16 | import timber.log.Timber 17 | 18 | 19 | class WorkoutDetailViewModel @ViewModelInject constructor( 20 | private val workoutRepository: WorkoutRepository, 21 | private val preferenceRepository: PreferenceRepository 22 | ) : ViewModel() { 23 | 24 | val workout: LiveData 25 | get() = TimerService.currentWorkout 26 | 27 | val volumeButtonState = preferenceRepository.soundStateFlow.asLiveData().map { 28 | it?.let{ VolumeButtonState.valueOf(it)} ?: VolumeButtonState.MUTE 29 | } 30 | 31 | val timerState: LiveData 32 | get() = workoutRepository.getTimerServiceTimerState() 33 | 34 | val workoutState: LiveData 35 | get() = workoutRepository.getTimerServiceWorkoutState() 36 | 37 | val repString: LiveData 38 | get() = workoutRepository.getTimerServiceRepetition().map { 39 | if(workout.value != null && it != -1) "$it/${workout.value?.repetitions}" 40 | else "" 41 | } 42 | 43 | val timeString: LiveData 44 | get() = workoutRepository.getTimerServiceElapsedTimeMillisESeconds().map { 45 | if(workout.value != null){ 46 | if(timerState.value != TimerState.EXPIRED) 47 | getFormattedStopWatchTime(it) 48 | else 49 | getFormattedStopWatchTime(TIMER_STARTING_IN_TIME) 50 | }else "" 51 | } 52 | 53 | val elapsedTime: LiveData 54 | get() = workoutRepository.getTimerServiceElapsedTimeMillis().map { 55 | //Timber.i("elapsedTime: $it") 56 | if(timerState.value != TimerState.EXPIRED) 57 | it 58 | else 59 | TIMER_STARTING_IN_TIME 60 | } 61 | 62 | val totalTime: LiveData 63 | get() = workoutRepository.getTimerServiceWorkoutState().map { 64 | Timber.i("totalTime: ${it.stateName}") 65 | if (timerState.value == TimerState.EXPIRED) 66 | TIMER_STARTING_IN_TIME 67 | else 68 | when(it){ 69 | WorkoutState.BREAK -> { 70 | workout.value?.pauseTime ?: 0L 71 | } 72 | WorkoutState.WORK -> { 73 | workout.value?.exerciseTime ?: 0L 74 | } 75 | else -> {TIMER_STARTING_IN_TIME} 76 | } 77 | } 78 | 79 | fun setSoundState(state: String) = viewModelScope.launch { 80 | preferenceRepository.setSoundState(state) 81 | Timber.d("Set volumeButtonState to: $state") 82 | } 83 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/ui/viewmodels/WorkoutListViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.ui.viewmodels 2 | 3 | import androidx.hilt.lifecycle.ViewModelInject 4 | import androidx.lifecycle.* 5 | import com.example.intimesimple.data.local.Workout 6 | import com.example.intimesimple.repositories.WorkoutRepository 7 | import kotlinx.coroutines.launch 8 | import timber.log.Timber 9 | 10 | class WorkoutListViewModel @ViewModelInject constructor( 11 | private val workoutRepository: WorkoutRepository 12 | ): ViewModel() { 13 | 14 | 15 | val workouts = workoutRepository.getAllWorkouts().asLiveData() 16 | 17 | fun addWorkout(workout: Workout){ 18 | viewModelScope.launch { 19 | workoutRepository.insertWorkout(workout) 20 | } 21 | } 22 | 23 | fun deleteWorkout(workout: Workout){ 24 | Timber.d("Deleting workout: ${workout.id}") 25 | viewModelScope.launch { 26 | workoutRepository.deleteWorkout(workout) 27 | } 28 | } 29 | 30 | fun deleteWorkoutWithId(wId: Long){ 31 | viewModelScope.launch { 32 | workoutRepository.deleteWorkoutWithId(wId) 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/utils/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.utils 2 | 3 | object Constants { 4 | const val DATABASE_NAME = "app_db" 5 | 6 | // Intent Actions for communication with timer service 7 | const val ACTION_START = "ACTION_START" 8 | const val ACTION_RESUME = "ACTION_RESUME" 9 | const val ACTION_PAUSE = "ACTION_PAUSE" 10 | const val ACTION_CANCEL = "ACTION_CANCEL" 11 | const val ACTION_CANCEL_AND_RESET = "ACTION_CANCEL_AND_RESET" 12 | const val ACTION_INITIALIZE_DATA = "ACTION_INITIALIZE_DATA" 13 | const val ACTION_MUTE = "ACTION_MUTE" 14 | const val ACTION_VIBRATE = "ACTION_VIBRATE" 15 | const val ACTION_SOUND = "ACTION_SOUND" 16 | const val ACTION_SHOW_MAIN_ACTIVITY = "ACTION_SHOW_MAIN_ACTIVITY" 17 | 18 | const val EXTRA_REPETITION = "EXTRA_REPETITION" 19 | const val EXTRA_EXERCISETIME = "EXTRA_EXERCISETIME" 20 | const val EXTRA_PAUSETIME = "EXTRA_PAUSETIME" 21 | const val EXTRA_WORKOUT_ID = "EXTRA_WORKOUT_ID" 22 | const val WORKOUT_DETAIL_URI = "https://example.com/workoutdetail?id=" 23 | 24 | const val NOTIFICATION_CHANNEL_ID = "timer_channel" 25 | const val NOTIFICATION_CHANNEL_NAME = "Timer" 26 | const val NOTIFICATION_ID = 1 27 | 28 | const val ONE_SECOND = 1000L 29 | const val TIMER_UPDATE_INTERVAL = 5L //5ms 30 | const val TIMER_STARTING_IN_TIME = 5000L //5s 31 | } -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/utils/ServiceUtils.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.utils 2 | 3 | import android.app.NotificationChannel 4 | import android.app.NotificationManager 5 | import android.app.PendingIntent 6 | import android.content.Context 7 | import android.content.Intent 8 | import android.net.Uri 9 | import android.os.Build 10 | import androidx.annotation.RequiresApi 11 | import com.example.intimesimple.MainActivity 12 | import com.example.intimesimple.data.local.Workout 13 | import com.example.intimesimple.data.local.WorkoutState 14 | import com.example.intimesimple.utils.Constants.NOTIFICATION_CHANNEL_ID 15 | import com.example.intimesimple.utils.Constants.NOTIFICATION_CHANNEL_NAME 16 | import com.example.intimesimple.utils.Constants.WORKOUT_DETAIL_URI 17 | import timber.log.Timber 18 | 19 | @RequiresApi(Build.VERSION_CODES.O) 20 | fun createNotificationChannel(notificationManager: NotificationManager) { 21 | val channel = NotificationChannel( 22 | NOTIFICATION_CHANNEL_ID, 23 | NOTIFICATION_CHANNEL_NAME, 24 | NotificationManager.IMPORTANCE_LOW 25 | ) 26 | notificationManager.createNotificationChannel(channel) 27 | } 28 | 29 | fun buildMainActivityPendingIntentWithId(id: Long, context: Context): PendingIntent { 30 | Timber.d("buildPendingIntentWithId - id: $id") 31 | return PendingIntent.getActivity( 32 | context, 33 | 0, 34 | Intent(context, MainActivity::class.java).also { 35 | it.action = Constants.ACTION_SHOW_MAIN_ACTIVITY 36 | it.putExtra(Constants.EXTRA_WORKOUT_ID, id) 37 | //Set data uri to deeplink uri -> automatically navigates when navGraph is created 38 | it.data = Uri.parse(WORKOUT_DETAIL_URI + "$id") 39 | }, 40 | PendingIntent.FLAG_UPDATE_CURRENT 41 | ) 42 | } 43 | 44 | fun getTimeFromWorkoutState( 45 | wasPaused: Boolean, 46 | state: WorkoutState, 47 | currentTime: Long, 48 | workout: Workout 49 | ): Long { 50 | return if (wasPaused) currentTime 51 | else { 52 | when (state) { 53 | WorkoutState.STARTING -> Constants.TIMER_STARTING_IN_TIME 54 | WorkoutState.BREAK -> workout.pauseTime 55 | else -> workout.exerciseTime 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/java/com/example/intimesimple/utils/Utils.kt: -------------------------------------------------------------------------------- 1 | package com.example.intimesimple.utils 2 | 3 | 4 | import android.os.Build 5 | import android.os.VibrationEffect 6 | import android.os.Vibrator 7 | import android.speech.tts.TextToSpeech 8 | import com.example.intimesimple.data.local.AudioState 9 | import com.example.intimesimple.data.local.VolumeButtonState 10 | import com.example.intimesimple.data.local.Workout 11 | import com.example.intimesimple.data.local.WorkoutState 12 | import com.example.intimesimple.utils.Constants.ACTION_MUTE 13 | import com.example.intimesimple.utils.Constants.ACTION_SOUND 14 | import com.example.intimesimple.utils.Constants.ACTION_VIBRATE 15 | import com.example.intimesimple.utils.Constants.ONE_SECOND 16 | import java.text.DateFormat 17 | import java.util.* 18 | import java.util.concurrent.TimeUnit 19 | import kotlin.math.abs 20 | import kotlin.math.max 21 | 22 | fun calculateRadiusOffset(strokeSize: Float, dotStrokeSize: Float, markerStrokeSize: Float) 23 | : Float { 24 | return max(strokeSize, max(dotStrokeSize, markerStrokeSize)) 25 | } 26 | 27 | fun getFormattedStopWatchTime(ms: Long?): String{ 28 | ms?.let { 29 | var milliseconds = ms 30 | 31 | // Convert to hours 32 | val hours = TimeUnit.MILLISECONDS.toHours(milliseconds) 33 | milliseconds -= TimeUnit.HOURS.toMillis(hours) 34 | 35 | // Convert to minutes 36 | val minutes = TimeUnit.MILLISECONDS.toMinutes(milliseconds) 37 | milliseconds -= TimeUnit.MINUTES.toMillis(minutes) 38 | 39 | // Convert to seconds 40 | val seconds = TimeUnit.MILLISECONDS.toSeconds(milliseconds) 41 | 42 | // Build formatted String 43 | return "${if(hours < 10) "0" else ""}$hours : " + 44 | "${if(minutes < 10) "0" else ""}$minutes : " + 45 | "${if(seconds < 10) "0" else ""}$seconds" 46 | } 47 | return "" 48 | } 49 | 50 | fun getFormattedCompletionTime(ms: Long?): String{ 51 | ms?.let { 52 | var milliseconds = ms 53 | // Convert to hours 54 | val hours = TimeUnit.MILLISECONDS.toHours(milliseconds) 55 | milliseconds -= TimeUnit.HOURS.toMillis(hours) 56 | 57 | // Convert to minutes 58 | val minutes = TimeUnit.MILLISECONDS.toMinutes(milliseconds) 59 | milliseconds -= TimeUnit.MINUTES.toMillis(minutes) 60 | 61 | // Convert to seconds 62 | val seconds = TimeUnit.MILLISECONDS.toSeconds(milliseconds) 63 | 64 | return (if(hours <= 0) "" else if(hours < 10) "0$hours:" else "$hours:") + 65 | (if(minutes <= 0) "" else if(minutes < 10) "0$minutes:" else "$minutes:" ) + 66 | "${if(seconds < 10) "0" else ""}$seconds" + 67 | if(hours > 0) " h" else if(minutes > 0) " min" else "sec" 68 | } 69 | return "" 70 | } 71 | 72 | fun convertLongToTime(time: Long?): String { 73 | time?.let { 74 | val date = Date(time) 75 | val format = DateFormat.getDateTimeInstance() 76 | return format.format(date) 77 | } 78 | return "No time found!" 79 | } 80 | 81 | fun convertDateToLong(date: String?): Long { 82 | date?.let { 83 | val df = DateFormat.getDateTimeInstance() 84 | df.parse(date)?.let { 85 | return it.time 86 | } 87 | } 88 | return 0L 89 | } 90 | 91 | fun millisToSeconds(ms: Long) = (ms/ONE_SECOND).toInt() 92 | 93 | fun getNextWorkoutState(state: WorkoutState) = when(state){ 94 | WorkoutState.STARTING -> WorkoutState.WORK 95 | WorkoutState.WORK -> WorkoutState.BREAK 96 | WorkoutState.BREAK -> WorkoutState.WORK 97 | } 98 | 99 | fun getNextAudioStateAction(audioState: AudioState) = when(audioState){ 100 | AudioState.MUTE -> ACTION_VIBRATE 101 | AudioState.VIBRATE -> ACTION_SOUND 102 | AudioState.SOUND -> ACTION_MUTE 103 | } 104 | 105 | fun audioStateToIcon(audioState: AudioState) = when(audioState){ 106 | AudioState.MUTE -> VolumeButtonState.MUTE.asset 107 | AudioState.VIBRATE -> VolumeButtonState.VIBRATE.asset 108 | AudioState.SOUND -> VolumeButtonState.SOUND.asset 109 | } 110 | 111 | fun getCompletionTimeForWorkout(workout: Workout): String { 112 | val reps = workout.repetitions 113 | val pauses = workout.repetitions - 1 114 | return getFormattedCompletionTime( 115 | reps * workout.exerciseTime + pauses * workout.pauseTime 116 | ) 117 | } 118 | 119 | fun getNextVolumeButtonState(state: VolumeButtonState) = when(state){ 120 | VolumeButtonState.MUTE -> VolumeButtonState.VIBRATE 121 | VolumeButtonState.VIBRATE -> VolumeButtonState.SOUND 122 | VolumeButtonState.SOUND -> VolumeButtonState.MUTE 123 | } 124 | 125 | fun getTimerActionFromVolumeButtonState(state: VolumeButtonState) = when(state){ 126 | VolumeButtonState.MUTE -> ACTION_MUTE 127 | VolumeButtonState.VIBRATE -> ACTION_VIBRATE 128 | VolumeButtonState.SOUND -> ACTION_SOUND 129 | } 130 | 131 | fun speakOrVibrate( 132 | tts: TextToSpeech?, 133 | vibrator: Vibrator, 134 | audioState: AudioState, 135 | sayText: String, 136 | vibrationLength: Long 137 | ) { 138 | when(audioState){ 139 | AudioState.MUTE -> return 140 | AudioState.VIBRATE -> vibrate(vibrator, vibrationLength) 141 | AudioState.SOUND -> ttsSpeak(tts, sayText) 142 | } 143 | } 144 | 145 | fun vibrate(vibrator: Vibrator, ms: Long){ 146 | if (vibrator.hasVibrator()) { // Vibrator availability checking 147 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 148 | vibrator.vibrate(VibrationEffect.createOneShot(ms, VibrationEffect.DEFAULT_AMPLITUDE)) 149 | } else { 150 | vibrator.vibrate(ms) // Vibrate method for below API Level 26 151 | } 152 | } 153 | } 154 | 155 | fun ttsSpeak(tts: TextToSpeech?, message: String){ 156 | tts?.speak(message, TextToSpeech.QUEUE_FLUSH, null, "") 157 | } 158 | 159 | val defaultWorkouts = listOf( 160 | Workout("15min Posture", 35000L, 15000L, 18), 161 | Workout("Morning Yoga", 30000L, 15000L, 12), 162 | Workout("Upper Body", 40000L, 20000L, 8), 163 | Workout("Lower Body", 40000L, 20000L, 8), 164 | Workout("Core Routine", 35000L, 25000L, 9), 165 | Workout("Conditioning", 50000L, 20000L, 5), 166 | Workout("15min Posture", 35000L, 15000L, 18), 167 | Workout("Morning Yoga", 30000L, 15000L, 12), 168 | Workout("Upper Body", 40000L, 20000L, 8), 169 | Workout("Lower Body", 40000L, 20000L, 8), 170 | Workout("Core Routine", 35000L, 25000L, 9), 171 | Workout("Conditioning", 50000L, 20000L, 5), 172 | ) 173 | 174 | fun getRandomWorkout() = defaultWorkouts.random() -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_alarm.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/font/robotocondensed_bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p-hlp/InTimeAndroid/cd7791266bb14e35791834ac1c004d90a9bfdd9e/app/src/main/res/font/robotocondensed_bold.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/robotocondensed_light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p-hlp/InTimeAndroid/cd7791266bb14e35791834ac1c004d90a9bfdd9e/app/src/main/res/font/robotocondensed_light.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/robotocondensed_regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p-hlp/InTimeAndroid/cd7791266bb14e35791834ac1c004d90a9bfdd9e/app/src/main/res/font/robotocondensed_regular.ttf -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p-hlp/InTimeAndroid/cd7791266bb14e35791834ac1c004d90a9bfdd9e/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p-hlp/InTimeAndroid/cd7791266bb14e35791834ac1c004d90a9bfdd9e/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p-hlp/InTimeAndroid/cd7791266bb14e35791834ac1c004d90a9bfdd9e/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p-hlp/InTimeAndroid/cd7791266bb14e35791834ac1c004d90a9bfdd9e/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p-hlp/InTimeAndroid/cd7791266bb14e35791834ac1c004d90a9bfdd9e/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p-hlp/InTimeAndroid/cd7791266bb14e35791834ac1c004d90a9bfdd9e/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p-hlp/InTimeAndroid/cd7791266bb14e35791834ac1c004d90a9bfdd9e/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p-hlp/InTimeAndroid/cd7791266bb14e35791834ac1c004d90a9bfdd9e/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p-hlp/InTimeAndroid/cd7791266bb14e35791834ac1c004d90a9bfdd9e/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p-hlp/InTimeAndroid/cd7791266bb14e35791834ac1c004d90a9bfdd9e/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/navigation/nav_graph.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 11 | 15 | 16 | 20 | 23 | 28 | 29 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12dp 4 | 4dp 5 | 16dp 6 | 360dp 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | INTime 3 | 4 | WorkoutList 5 | WorkoutAdd 6 | WorkoutDetail 7 | Add a workout 8 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | 25 | 26 |