diff --git a/.all-contributorsrc b/.all-contributorsrc new file mode 100644 index 000000000..f9c07b723 --- /dev/null +++ b/.all-contributorsrc @@ -0,0 +1,165 @@ +{ + "files": [ + "README.md" + ], + "imageSize": 100, + "commit": false, + "contributors": [ + { + "login": "pizzapim", + "name": "pizzapim", + "avatar_url": "https://avatars3.githubusercontent.com/u/23135512?v=4", + "profile": "https://github.com/pizzapim", + "contributions": [ + "code" + ] + }, + { + "login": "Poussinou", + "name": "Poussinou", + "avatar_url": "https://avatars2.githubusercontent.com/u/19646549?v=4", + "profile": "https://github.com/Poussinou", + "contributions": [ + "doc" + ] + }, + { + "login": "xerus2000", + "name": "Janek", + "avatar_url": "https://avatars3.githubusercontent.com/u/13354331?v=4", + "profile": "https://github.com/xerus2000", + "contributions": [ + "ideas", + "bug" + ] + }, + { + "login": "IzzySoft", + "name": "Izzy", + "avatar_url": "https://avatars3.githubusercontent.com/u/6781438?v=4", + "profile": "https://android.izzysoft.de/", + "contributions": [ + "infra" + ] + }, + { + "login": "hhpmmd", + "name": "hhpmmd", + "avatar_url": "https://avatars0.githubusercontent.com/u/77058867?v=4", + "profile": "https://github.com/hhpmmd", + "contributions": [ + "code", + "bug" + ] + }, + { + "login": "Kozonak", + "name": "Kozonak", + "avatar_url": "https://avatars.githubusercontent.com/u/3283978?v=4", + "profile": "https://github.com/Kozonak", + "contributions": [ + "bug" + ] + }, + { + "login": "tifriis208", + "name": "Tifriis", + "avatar_url": "https://avatars.githubusercontent.com/u/40381365?v=4", + "profile": "https://github.com/tifriis208", + "contributions": [ + "translation" + ] + }, + { + "login": "sguinetti", + "name": "Diego", + "avatar_url": "https://avatars.githubusercontent.com/u/5572928?v=4", + "profile": "http://sguinetti.gitlab.io", + "contributions": [ + "translation" + ] + }, + { + "login": "stheid", + "name": "Stefan Heid", + "avatar_url": "https://avatars.githubusercontent.com/u/2736207?v=4", + "profile": "https://github.com/stheid", + "contributions": [ + "code" + ] + }, + { + "login": "Pada0", + "name": "Pada0", + "avatar_url": "https://avatars.githubusercontent.com/u/73777780?v=4", + "profile": "https://github.com/Pada0", + "contributions": [ + "translation" + ] + }, + { + "login": "yuhuitech", + "name": "Yuhui Su", + "avatar_url": "https://avatars.githubusercontent.com/u/25502419?v=4", + "profile": "https://github.com/yuhuitech", + "contributions": [ + "code" + ] + }, + { + "login": "steel-steven-87", + "name": "steel-steven-87", + "avatar_url": "https://avatars.githubusercontent.com/u/89798696?v=4", + "profile": "https://github.com/steel-steven-87", + "contributions": [ + "bug" + ] + }, + { + "login": "stfnw", + "name": "Stefan Walter", + "avatar_url": "https://avatars.githubusercontent.com/u/28357058?v=4", + "profile": "https://github.com/stfnw", + "contributions": [ + "translation" + ] + }, + { + "login": "PropellantDyke", + "name": "PropellantDyke", + "avatar_url": "https://avatars.githubusercontent.com/u/118541570?v=4", + "profile": "https://github.com/PropellantDyke", + "contributions": [ + "code", + "design", + "translation" + ] + }, + { + "login": "dece", + "name": "Adrien Abraham", + "avatar_url": "https://avatars.githubusercontent.com/u/6729523?v=4", + "profile": "https://git.dece.space/dece", + "contributions": [ + "translation" + ] + }, + { + "login": "ballinger", + "name": "ballinger", + "avatar_url": "https://avatars.githubusercontent.com/u/1741638?v=4", + "profile": "https://github.com/ballinger", + "contributions": [ + "bug" + ] + } + ], + "contributorsPerLine": 7, + "projectName": "track-and-graph", + "projectOwner": "SamAmco", + "repoType": "github", + "repoHost": "https://github.com", + "skipCi": true, + "commitConvention": "angular", + "commitType": "docs" +} diff --git a/.gitignore b/.gitignore index 7bb2ff950..9bf8d4dc1 100644 --- a/.gitignore +++ b/.gitignore @@ -71,5 +71,10 @@ lint/outputs/ lint/tmp/ # lint/reports/ database-extract/ge_database.db +database-extract/ge_database.db-shm +database-extract/ge_database.db-wal + local-scripts/ + +todo.txt diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 000000000..bea438e9a --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.3.1 diff --git a/Gemfile b/Gemfile new file mode 100644 index 000000000..7a118b49b --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "fastlane" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..2fbe98c70 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,219 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.7) + base64 + nkf + rexml + addressable (2.8.6) + public_suffix (>= 2.0.2, < 6.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.3.0) + aws-partitions (1.944.0) + aws-sdk-core (3.197.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.651.0) + aws-sigv4 (~> 1.8) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.84.0) + aws-sdk-core (~> 3, >= 3.197.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.152.3) + aws-sdk-core (~> 3, >= 3.197.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.8) + aws-sigv4 (1.8.0) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.2.0) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + declarative (0.0.20) + digest-crc (0.6.5) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.110.0) + faraday (1.10.3) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.7) + faraday (>= 0.8.0) + http-cookie (~> 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.4) + multipart-post (~> 2) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.0) + faraday (~> 1.0) + fastimage (2.3.1) + fastlane (2.220.0) + CFPropertyList (>= 2.3, < 4.0.0) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored (~> 1.2) + commander (~> 4.6) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.0.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + naturally (~> 2.2) + optparse (>= 0.1.1, < 1.0.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.3.0) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.54.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.3) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.31.0) + google-apis-core (>= 0.11.0, < 2.a) + google-cloud-core (1.7.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.4.0) + google-cloud-storage (1.47.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.31.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.8.1) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.6) + domain_name (~> 0.5) + httpclient (2.8.3) + jmespath (1.6.2) + json (2.7.2) + jwt (2.8.1) + base64 + mini_magick (4.13.1) + mini_mime (1.1.5) + multi_json (1.15.0) + multipart-post (2.4.1) + nanaimo (0.3.0) + naturally (2.2.1) + nkf (0.2.0) + optparse (0.5.0) + os (1.1.4) + plist (3.7.1) + public_suffix (5.1.0) + rake (13.2.1) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.1.2) + rexml (3.2.9) + strscan + rouge (2.0.7) + ruby2_keywords (0.0.5) + rubyzip (2.3.2) + security (0.1.5) + signet (0.19.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + strscan (3.1.0) + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unicode-display_width (2.5.0) + word_wrap (1.0.0) + xcodeproj (1.24.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.3.0) + rexml (~> 3.2.4) + xcpretty (0.3.0) + rouge (~> 2.0.7) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + ruby + +DEPENDENCIES + fastlane + +BUNDLED WITH + 2.5.9 diff --git a/LICENSE.txt b/LICENSE.txt index d64569567..f288702d2 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,202 +1,674 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 000000000..f478bdbf2 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# Track & Graph + +[![All Contributors](https://img.shields.io/badge/all_contributors-16-orange.svg?style=flat-square)](#contributors-) + +An android app for tracking personal data and creating custom graphs + +

+ + Get it on Google Play + + + Get it on F-Droid + +

+ +# Screenshots +

+ + + + + + + +

+ +# Privacy Policy +This app does not record any of your personal information, anything you track or anything you do in the app. Nothing is recorded or stored anywhere online by this app or any third party library used by this app. You can however easily backup your data at any time to your own device or cloud storage service from within the app. + +# Contributing +Contributers are welcome. Please raise an issue through GitHub for bug fix or feature requests. + +# License + Copyright (C) 2019-2020 SamAmco + + Track & Graph is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by the + Free Software Foundation, either version 3 of the License, or (at your + option) any later version. + + Track & Graph is distributed in the hope that it will be useful, but + WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + more details. + + You should have received a copy of the GNU General Public License along + with this program. If not, see . + +## Contributors โœจ + +Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
pizzapim
pizzapim

๐Ÿ’ป
Poussinou
Poussinou

๐Ÿ“–
Janek
Janek

๐Ÿค” ๐Ÿ›
Izzy
Izzy

๐Ÿš‡
hhpmmd
hhpmmd

๐Ÿ’ป ๐Ÿ›
Kozonak
Kozonak

๐Ÿ›
Tifriis
Tifriis

๐ŸŒ
Diego
Diego

๐ŸŒ
Stefan Heid
Stefan Heid

๐Ÿ’ป
Pada0
Pada0

๐ŸŒ
Yuhui Su
Yuhui Su

๐Ÿ’ป
steel-steven-87
steel-steven-87

๐Ÿ›
Stefan Walter
Stefan Walter

๐ŸŒ
PropellantDyke
PropellantDyke

๐Ÿ’ป ๐ŸŽจ ๐ŸŒ
Adrien Abraham
Adrien Abraham

๐ŸŒ
ballinger
ballinger

๐Ÿ›
+ + + + + + +This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! + +# Donate crypto to the creator + +
+ +| | | +|:--:|:--:| +| bc1q3a74v98lklzqsgrennlveujfwgluusld7hqsvu | 0xf2bae4971f67dfb49a2a4a08449980657abfb965 | +| | | +| qr266rwh2t4jajlvekudn89vlh6nd6u4wsj3ngv45m | ltc1q0ayy9ctvnnk7wyqgmh80ujq6wry3saz9a00d28 | + +
diff --git a/app/build.gradle b/app/build.gradle index 698353095..c0d92a3d5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,93 +1,158 @@ -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-android-extensions' -apply plugin: 'kotlin-kapt' -apply plugin: "androidx.navigation.safeargs.kotlin" - +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.kapt' + id 'com.google.devtools.ksp' + id "androidx.navigation.safeargs.kotlin" + id "dagger.hilt.android.plugin" +} android { - compileSdkVersion 29 - buildToolsVersion "29.0.2" + compileSdk 34 compileOptions { - sourceCompatibility 1.8 - targetCompatibility 1.8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "17" + //Using experimental kotlin co-routines api's + freeCompilerArgs += [ + "-opt-in=kotlin.RequiresOptIn", + // The below option is here because of this issue: https://github.com/Kotlin/kotlinx.serialization/issues/2145 + "-Xstring-concat=inline" + ] } defaultConfig { applicationId "com.samco.trackandgraph" - minSdkVersion 19 - targetSdkVersion 29 - versionCode 1 - versionName "1.0" + minSdkVersion 23 + targetSdkVersion 34 + //If the backup file is not backwards compatible after this update, upgrade the major version number! + versionCode 400003 + versionName "4.0.3" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + vectorDrawables { + useSupportLibrary true + } } buildTypes { debug { minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + applicationIdSuffix = ".debug" + resValue "string", "app_name", "Debug Track & Graph" + } + debugMinify { + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + applicationIdSuffix = ".debug.minify" + resValue "string", "app_name", "Debug Minify Track & Graph" + signingConfig signingConfigs.debug } release { minifyEnabled true + shrinkResources true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + resValue "string", "app_name", "Track & Graph" + ndk.debugSymbolLevel = "SYMBOL_TABLE" } } - dataBinding { - enabled = true + + buildFeatures { + dataBinding true + compose true + } + + //If you get an error about compose not supporting your kotlin compiler version + // you might need to look into this: + //https://developer.android.com/jetpack/androidx/releases/compose-kotlin + composeOptions { + kotlinCompilerExtensionVersion = "1.5.14" } + + namespace 'com.samco.trackandgraph' } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" - implementation 'androidx.appcompat:appcompat:1.1.0' - implementation 'androidx.constraintlayout:constraintlayout:1.1.3' - implementation 'androidx.legacy:legacy-support-v4:1.0.0' + + implementation project(":functions") + + implementation libs.kotlin.reflect + implementation libs.androidx.constraintlayout + implementation libs.androidx.legacy.support.v4 + + //Dependency Injection + implementation libs.hilt.android + ksp libs.hilt.compiler + ksp(libs.androidx.hilt.compiler) + implementation libs.hilt.navigation.compose //UI - implementation 'androidx.fragment:fragment:1.1.0' - implementation 'androidx.cardview:cardview:1.0.0' - implementation 'androidx.recyclerview:recyclerview:1.0.0' - implementation 'com.google.android.material:material:1.0.0' + implementation libs.androidx.cardview + implementation libs.androidx.recyclerview + + implementation libs.compose.ui + implementation libs.compose.ui.tooling + implementation libs.compose.foundation + implementation libs.compose.ui.viewbinding + implementation libs.androidx.activity.compose + implementation libs.lifecycle.viewmodel.compose + implementation libs.lifecycle.runtime.compose + implementation libs.compose.runtime.livedata + // Material design stuff you might need at some point + implementation libs.compose.material + implementation libs.compose.theme.adapter + implementation libs.accompanist.pager // Android KTX - implementation 'androidx.core:core-ktx:1.1.0' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0' + implementation libs.androidx.lifecycle.viewmodel.ktx + implementation libs.androidx.lifecycle.livedata.ktx + implementation libs.androidx.fragment.ktx //Navigation - implementation "androidx.navigation:navigation-fragment-ktx:$nav_version" - implementation "androidx.navigation:navigation-ui-ktx:$nav_version" + implementation libs.androidx.navigation.fragment.ktx + implementation libs.androidx.navigation.ui.ktx //Lifecycle extensions - implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0' - - //Room - implementation "androidx.room:room-runtime:$room_version" - kapt "androidx.room:room-compiler:$room_version" - implementation "androidx.room:room-ktx:$room_version" + implementation libs.androidx.lifecycle.extensions // Coroutines - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutine_version" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutine_version" - - //Timber - implementation 'com.jakewharton.timber:timber:4.7.1' - - //Date/Time - implementation 'com.jakewharton.threetenabp:threetenabp:1.2.1' - testImplementation 'org.threeten:threetenbp:1.4.0' - - //CSV - implementation 'org.apache.commons:commons-csv:1.7' + implementation libs.kotlinx.coroutines.core + implementation libs.kotlinx.coroutines.android //Graph drawing - implementation 'com.androidplot:androidplot-core:1.5.7' - implementation 'com.google.android:flexbox:1.1.1' - - - testImplementation 'junit:junit:4.12' - androidTestImplementation 'androidx.test:runner:1.2.0' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + implementation libs.androidplot.core + implementation libs.flexbox + + //Work manager + implementation libs.hilt.work + implementation libs.androidx.work.runtime.ktx + + //Testing + testImplementation libs.threetenbp + testImplementation libs.junit + testImplementation libs.mockito.kotlin + testImplementation libs.kotlinx.coroutines.test + testImplementation libs.androidx.core.testing } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index ba939ddd7..f72fedf83 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -21,3 +21,6 @@ #-renamesourcefileattribute SourceFile -keep class com.androidplot.** { *; } +-keepnames class androidx.navigation.fragment.NavHostFragment +-keepattributes LineNumberTable,SourceFile +-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/samco/trackandgraph/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/samco/trackandgraph/ExampleInstrumentedTest.kt deleted file mode 100644 index 86e11b5d0..000000000 --- a/app/src/androidTest/java/com/samco/trackandgraph/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.samco.trackandgraph - -import androidx.test.InstrumentationRegistry -import androidx.test.runner.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getTargetContext() - assertEquals("com.samco.trackandgraph", appContext.packageName) - } -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7b06b7cae..2120f9c24 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,28 +1,122 @@ - - + xmlns:tools="http://schemas.android.com/tools"> + + + + + + + + + + + + android:name="com.samco.trackandgraph.TrackAndGraphApplication" + android:allowBackup="true" + android:dataExtractionRules="@xml/backup_rules" + android:fullBackupContent="true" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:roundIcon="@mipmap/ic_launcher_round" + android:supportsRtl="true" + android:theme="@style/AppTheme"> + + + + + + + + + android:name="com.samco.trackandgraph.MainActivity" + android:exported="true" + android:theme="@style/AppTheme" + android:windowSoftInputMode="stateAlwaysHidden|adjustResize"> - + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/Constants.kt b/app/src/main/java/com/samco/trackandgraph/Constants.kt new file mode 100644 index 000000000..bf2a779ac --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/Constants.kt @@ -0,0 +1,92 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph + +import com.samco.trackandgraph.base.database.dto.LineGraphAveraginModes +import com.samco.trackandgraph.base.database.dto.LineGraphPlottingModes +import com.samco.trackandgraph.base.database.dto.TimeHistogramWindow +import org.threeten.bp.Duration +import org.threeten.bp.Period +import org.threeten.bp.temporal.TemporalAmount + +data class TimeHistogramWindowData( + val window: TimeHistogramWindow, + val period: TemporalAmount, + val numBins: Int +) { + companion object { + fun getWindowData(window: TimeHistogramWindow): TimeHistogramWindowData = when (window) { + TimeHistogramWindow.HOUR -> TimeHistogramWindowData( + window = TimeHistogramWindow.HOUR, + period = Duration.ofHours(1), + numBins = 60 + ) + TimeHistogramWindow.DAY -> TimeHistogramWindowData( + window = TimeHistogramWindow.DAY, + period = Period.ofDays(1), + numBins = 24 + ) + TimeHistogramWindow.WEEK -> TimeHistogramWindowData( + window = TimeHistogramWindow.WEEK, + period = Period.ofWeeks(1), + numBins = 7 + ) + TimeHistogramWindow.MONTH -> TimeHistogramWindowData( + window = TimeHistogramWindow.MONTH, + period = Period.ofMonths(1), + numBins = 30 + ) + TimeHistogramWindow.THREE_MONTHS -> TimeHistogramWindowData( + window = TimeHistogramWindow.THREE_MONTHS, + period = Period.ofMonths(3), + numBins = 13 + ) + TimeHistogramWindow.SIX_MONTHS -> TimeHistogramWindowData( + window = TimeHistogramWindow.SIX_MONTHS, + period = Period.ofMonths(6), + numBins = 26 + ) + TimeHistogramWindow.YEAR -> TimeHistogramWindowData( + window = TimeHistogramWindow.YEAR, + period = Period.ofYears(1), + numBins = 12 + ) + } + } +} + + +val movingAverageDurations = mapOf( + LineGraphAveraginModes.NO_AVERAGING to null, + LineGraphAveraginModes.DAILY_MOVING_AVERAGE to Duration.ofDays(1), + LineGraphAveraginModes.THREE_DAY_MOVING_AVERAGE to Duration.ofDays(3), + LineGraphAveraginModes.WEEKLY_MOVING_AVERAGE to Duration.ofDays(7), + LineGraphAveraginModes.MONTHLY_MOVING_AVERAGE to Duration.ofDays(31), + LineGraphAveraginModes.THREE_MONTH_MOVING_AVERAGE to Duration.ofDays(93), + LineGraphAveraginModes.SIX_MONTH_MOVING_AVERAGE to Duration.ofDays(183), + LineGraphAveraginModes.YEARLY_MOVING_AVERAGE to Duration.ofDays(365) +) + +val plottingModePeriods: Map = mapOf( + LineGraphPlottingModes.WHEN_TRACKED to null, + LineGraphPlottingModes.GENERATE_HOURLY_TOTALS to Duration.ofHours(1), + LineGraphPlottingModes.GENERATE_DAILY_TOTALS to Period.ofDays(1), + LineGraphPlottingModes.GENERATE_WEEKLY_TOTALS to Period.ofWeeks(1), + LineGraphPlottingModes.GENERATE_MONTHLY_TOTALS to Period.ofMonths(1), + LineGraphPlottingModes.GENERATE_YEARLY_TOTALS to Period.ofYears(1) +) diff --git a/app/src/main/java/com/samco/trackandgraph/MainActivity.kt b/app/src/main/java/com/samco/trackandgraph/MainActivity.kt index c3763a57e..42cfcbe47 100644 --- a/app/src/main/java/com/samco/trackandgraph/MainActivity.kt +++ b/app/src/main/java/com/samco/trackandgraph/MainActivity.kt @@ -1,54 +1,328 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ package com.samco.trackandgraph -import androidx.appcompat.app.AppCompatActivity +import android.content.res.Configuration import android.os.Bundle -import android.view.Menu +import android.os.PersistableBundle +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.AdapterView.OnItemSelectedListener +import android.widget.ArrayAdapter +import android.widget.ImageView +import androidx.activity.viewModels +import androidx.appcompat.app.ActionBarDrawerToggle +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.appcompat.widget.AppCompatSpinner +import androidx.appcompat.widget.Toolbar import androidx.core.view.GravityCompat import androidx.drawerlayout.widget.DrawerLayout +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import androidx.navigation.NavController -import androidx.navigation.findNavController -import androidx.navigation.ui.AppBarConfiguration -import androidx.navigation.ui.setupWithNavController +import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.navigateUp -import androidx.navigation.ui.setupActionBarWithNavController +import androidx.navigation.ui.setupWithNavController +import androidx.viewpager.widget.ViewPager import com.google.android.material.navigation.NavigationView +import com.samco.trackandgraph.base.helpers.* +import com.samco.trackandgraph.base.model.AlarmInteractor +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.tutorial.TutorialPagerAdapter +import com.samco.trackandgraph.util.* +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.launch +import javax.inject.Inject + +enum class NavButtonStyle { UP, MENU } +@AndroidEntryPoint class MainActivity : AppCompatActivity() { private lateinit var drawerLayout: DrawerLayout - private lateinit var appBarConfiguration: AppBarConfiguration private lateinit var navController: NavController private lateinit var navView: NavigationView + private lateinit var navHostFragment: NavHostFragment + private lateinit var actionBarDrawerToggle: ActionBarDrawerToggle - private val navFragments = setOf( - R.id.selectTrackGroupFragment, - R.id.selectGraphStatGroupFragment, - R.id.aboutPageFragment - ) + private lateinit var currentNavBarConfig: NavBarConfig + + @Inject + lateinit var dataInteractor: DataInteractor + + @Inject + lateinit var prefHelper: PrefHelper + + private val viewModel by viewModels() + + val toolbar: Toolbar by lazy { findViewById(R.id.toolbar) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + readThemeValue() setContentView(R.layout.activity_main) + initializeNav() + initializeAppBar() + onDrawerHideKeyboard() + initDrawerSpinners() + viewModel.syncAlarms() + if (prefHelper.isFirstRun()) showTutorial() + else destroyTutorial() + } + private fun initializeNav() { drawerLayout = findViewById(R.id.drawer_layout) - navController = findNavController(R.id.nav_fragment) - appBarConfiguration = AppBarConfiguration(navFragments, drawerLayout) + navHostFragment = supportFragmentManager + .findFragmentById(R.id.nav_fragment)!! as NavHostFragment + navController = navHostFragment.navController navView = findViewById(R.id.nav_view) - navView.itemIconTintList = null - - setSupportActionBar(findViewById(R.id.toolbar)) - setupActionBarWithNavController(navController, appBarConfiguration) navView.setupWithNavController(navController) } + private fun initializeAppBar() { + setSupportActionBar(toolbar) + supportActionBar?.let { + //The ActionBarDrawerToggle draws the navigation button/back button in the top left of + //the action bar + actionBarDrawerToggle = ActionBarDrawerToggle( + this, drawerLayout, toolbar, R.string.open, R.string.close + ) + //This click listener is called only when the button is "disabled" (i.e. the back button + // is showing rather than the hamburger icon) + actionBarDrawerToggle.setToolbarNavigationClickListener { onBackPressed() } + //This notifies the button of when the drawer is open or closed + drawerLayout.addDrawerListener(actionBarDrawerToggle) + //This function should be called to synchronise the button with the drawers current state + actionBarDrawerToggle.syncState() + } + //supportActionBar?.setHomeButtonEnabled(true) + //supportActionBar?.setDisplayShowHomeEnabled(true) + setActionBarConfig(NavBarConfig(NavButtonStyle.MENU)) + } + + override fun onPostCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { + super.onPostCreate(savedInstanceState, persistentState) + //See the documentation of syncState for the eplanation of why this is done here + actionBarDrawerToggle.syncState() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + //Doing this here was reccomended on stack overflow. See the documentation of syncState + actionBarDrawerToggle.syncState() + } + + private data class NavBarConfig( + val buttonStyle: NavButtonStyle, + val title: String? = null, + val clearSubtitle: Boolean = false + ) + + fun setActionBarSubtitle(subtitle: String? = null) { + supportActionBar?.subtitle = subtitle + } + + /** + * Set the title in the action bar and whether to show the menu button or the back button + * in the top left. Every fragment should call this. + */ + fun setActionBarConfig(buttonStyle: NavButtonStyle, title: String? = null, clearSubtitle: Boolean = false) { + setActionBarConfig(NavBarConfig(buttonStyle, title, clearSubtitle)) + } + + private fun setActionBarConfig(config: NavBarConfig) { + currentNavBarConfig = config + val title = config.title ?: getString(R.string.app_name) + supportActionBar?.title = title + + if (!config.clearSubtitle) + { + supportActionBar?.subtitle = null + } + + when (config.buttonStyle) { + NavButtonStyle.MENU -> { + actionBarDrawerToggle.isDrawerIndicatorEnabled = true + supportActionBar?.setDisplayHomeAsUpEnabled(false) + } + NavButtonStyle.UP -> { + actionBarDrawerToggle.isDrawerIndicatorEnabled = false + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + } + actionBarDrawerToggle.syncState() + } + + private fun initDrawerSpinners() { + setUpThemeSpinner() + setUpDateFormatSpinner() + } + + private fun setUpDateFormatSpinner() { + val spinner = navView.menu.findItem(R.id.dateFormatSpinner).actionView as AppCompatSpinner + val formatNames = resources.getStringArray(R.array.date_formats) + spinner.adapter = ArrayAdapter( + this, + android.R.layout.simple_spinner_dropdown_item, + formatNames + ) + spinner.setSelection(prefHelper.getDateFormatValue()) + spinner.onItemSelectedListener = object : OnItemSelectedListener { + override fun onItemSelected(av: AdapterView<*>?, v: View?, position: Int, id: Long) { + prefHelper.setDateTimeFormatIndex(position) + } + + override fun onNothingSelected(p0: AdapterView<*>?) { + } + } + } + + private fun setUpThemeSpinner() { + val spinner = navView.menu.findItem(R.id.themeSpinner).actionView as AppCompatSpinner + spinner.adapter = ArrayAdapter( + this, + android.R.layout.simple_spinner_dropdown_item, + getThemeNames() + ) + val currentTheme = getThemeValue() + updateStatusBarColor() + when (currentTheme) { + AppCompatDelegate.MODE_NIGHT_NO -> spinner.setSelection(1) + AppCompatDelegate.MODE_NIGHT_YES -> spinner.setSelection(2) + else -> spinner.setSelection(0) + } + spinner.onItemSelectedListener = object : OnItemSelectedListener { + override fun onItemSelected(av: AdapterView<*>?, v: View?, position: Int, id: Long) { + onThemeSelected(position) + updateStatusBarColor() + } + + override fun onNothingSelected(parent: AdapterView<*>?) {} + } + } + + private fun updateStatusBarColor() { + window.statusBarColor = getColorFromAttr(R.attr.colorSecondaryVariant) + } + + private fun onThemeSelected(position: Int) { + when (position) { + 0 -> setThemeValue(getDefaultThemeValue()) + 1 -> setThemeValue(AppCompatDelegate.MODE_NIGHT_NO) + 2 -> setThemeValue(AppCompatDelegate.MODE_NIGHT_YES) + } + } + + private fun getDefaultThemeValue() = + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + else AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY + + private fun getThemeValue() = prefHelper.getThemeValue(getDefaultThemeValue()) + + private fun setThemeValue(themeValue: Int) { + AppCompatDelegate.setDefaultNightMode(themeValue) + prefHelper.setThemeValue(themeValue) + } + + private fun readThemeValue() { + val themeValue = getThemeValue() + AppCompatDelegate.setDefaultNightMode(themeValue) + } + + private fun getThemeNames() = + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) + resources.getStringArray(R.array.theme_names_Q) + else resources.getStringArray(R.array.theme_names_pre_Q) + + private fun onDrawerHideKeyboard() { + drawerLayout.addDrawerListener(object : DrawerLayout.DrawerListener { + override fun onDrawerStateChanged(newState: Int) {} + override fun onDrawerSlide(drawerView: View, slideOffset: Float) { + window.hideKeyboard() + } + + override fun onDrawerClosed(drawerView: View) {} + override fun onDrawerOpened(drawerView: View) { + window.hideKeyboard() + } + }) + } + + private fun destroyTutorial() { + val tutorialLayout = findViewById(R.id.tutorialOverlay) + tutorialLayout.removeAllViews() + prefHelper.setFirstRun(false) + } + + private fun showTutorial() { + val pips = listOf( + findViewById(R.id.pip1), + findViewById(R.id.pip2), + findViewById(R.id.pip3) + ) + val viewPager = findViewById(R.id.tutorialViewPager) + val refreshPips = { position: Int -> + pips.forEachIndexed { i, p -> p.alpha = if (i == position) 1f else 0.5f } + } + refreshPips.invoke(0) + viewPager.visibility = View.VISIBLE + viewPager.adapter = TutorialPagerAdapter(applicationContext, this::destroyTutorial) + viewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener { + override fun onPageScrollStateChanged(state: Int) {} + override fun onPageScrolled( + position: Int, + positionOffset: Float, + positionOffsetPixels: Int + ) { + } + + override fun onPageSelected(position: Int) { + refreshPips.invoke(position) + } + }) + } + + override fun onSupportNavigateUp(): Boolean { - return navController.navigateUp(appBarConfiguration) || super.onNavigateUp() + return navController.navigateUp(drawerLayout) || super.onNavigateUp() } override fun onBackPressed() { if (drawerLayout.isDrawerOpen(GravityCompat.START)) { drawerLayout.closeDrawer(GravityCompat.START) } else { - super.onBackPressed() + onBackPressedDispatcher.onBackPressed() } } } + +@HiltViewModel +class MainActivityViewModel @Inject constructor( + private val alarmInteractor: AlarmInteractor, + @IODispatcher private val io: CoroutineDispatcher +) : ViewModel() { + fun syncAlarms() { + viewModelScope.launch(io) { + alarmInteractor.syncAlarms() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/TrackAndGraphApplication.kt b/app/src/main/java/com/samco/trackandgraph/TrackAndGraphApplication.kt index bd6d3f761..8094c4882 100644 --- a/app/src/main/java/com/samco/trackandgraph/TrackAndGraphApplication.kt +++ b/app/src/main/java/com/samco/trackandgraph/TrackAndGraphApplication.kt @@ -1,13 +1,44 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ package com.samco.trackandgraph import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration import com.jakewharton.threetenabp.AndroidThreeTen +import dagger.hilt.android.HiltAndroidApp import timber.log.Timber +import javax.inject.Inject + +@HiltAndroidApp +class TrackAndGraphApplication : Application(), Configuration.Provider { + + @Inject + lateinit var hiltWorkerFactory: HiltWorkerFactory -class TrackAndGraphApplication : Application() { override fun onCreate() { super.onCreate() Timber.plant(Timber.DebugTree()) AndroidThreeTen.init(this) } + + override val workManagerConfiguration: Configuration by lazy { + Configuration.Builder() + .setWorkerFactory(hiltWorkerFactory) + .build() + } } \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/aboutpage/AboutPageFragment.kt b/app/src/main/java/com/samco/trackandgraph/aboutpage/AboutPageFragment.kt index e3410da72..b96226e71 100644 --- a/app/src/main/java/com/samco/trackandgraph/aboutpage/AboutPageFragment.kt +++ b/app/src/main/java/com/samco/trackandgraph/aboutpage/AboutPageFragment.kt @@ -1,3 +1,19 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ package com.samco.trackandgraph.aboutpage import android.os.Bundle @@ -5,20 +21,57 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment -import com.samco.trackandgraph.databinding.AboutPageBinding import android.content.Intent import android.net.Uri +import androidx.compose.ui.platform.ComposeView +import com.samco.trackandgraph.MainActivity +import com.samco.trackandgraph.NavButtonStyle import com.samco.trackandgraph.R - +import com.samco.trackandgraph.ui.compose.theming.TnGComposeTheme +import timber.log.Timber class AboutPageFragment : Fragment() { - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - val binding = AboutPageBinding.inflate(inflater, container, false) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val versionText = getVersionText() + + return ComposeView(requireContext()).apply { + setContent { + TnGComposeTheme { + AboutPageView( + versionText = versionText, + onRepoLinkClicked = { onRepoLinkClicked() } + ) + } + } + } + } + + private fun onRepoLinkClicked() { val url = getString(R.string.github_link) - binding.githubLinkButton.setOnClickListener { - val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - startActivity(browserIntent) + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + startActivity(browserIntent) + } + + private fun getVersionText(): String { + return try { + val pInfo = requireContext().packageManager + .getPackageInfo(requireActivity().packageName, 0) + "v${pInfo.versionName}" + } catch (e: Exception) { + Timber.d("Could not get package version name: ${e.message}") + "" } - return binding.root + } + + override fun onResume() { + super.onResume() + (requireActivity() as MainActivity).setActionBarConfig( + NavButtonStyle.MENU, + getString(R.string.about) + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/aboutpage/AboutPageView.kt b/app/src/main/java/com/samco/trackandgraph/aboutpage/AboutPageView.kt new file mode 100644 index 000000000..46ef7546e --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/aboutpage/AboutPageView.kt @@ -0,0 +1,175 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.aboutpage + +import android.widget.ImageView +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.samco.trackandgraph.R +import com.samco.trackandgraph.ui.compose.theming.TnGComposeTheme +import com.samco.trackandgraph.ui.compose.ui.HalfDialogInputSpacing +import com.samco.trackandgraph.ui.compose.ui.InputSpacingLarge +import com.samco.trackandgraph.ui.compose.ui.InputSpacingXLarge +import com.samco.trackandgraph.ui.compose.ui.TextBody1 +import com.samco.trackandgraph.ui.compose.ui.TextLink +import com.samco.trackandgraph.ui.compose.ui.TextSubtitle2 +import com.samco.trackandgraph.ui.compose.ui.dialogInputSpacing + +@Composable +fun AboutPageView( + versionText: String, + onRepoLinkClicked: () -> Unit = {} +) = Column( + modifier = Modifier + .padding(dialogInputSpacing) + .fillMaxSize() + .verticalScroll(rememberScrollState()) +) { + VersionText(versionText) + InputSpacingXLarge() + TnGIcon(modifier = Modifier.align(Alignment.CenterHorizontally)) + InputSpacingXLarge() + Headline() + HalfDialogInputSpacing() + RepoLink(onLinkClicked = onRepoLinkClicked) + InputSpacingXLarge() + AboutLibraries() + InputSpacingLarge() + LibraryTable() +} + +@Composable +fun LibraryTable() = Row { + Spacer(modifier = Modifier.weight(1f)) + LibraryNames() + Spacer(modifier = Modifier.weight(1f)) + LibraryVersions() + Spacer(modifier = Modifier.weight(1f)) +} + + +@Composable +fun LibraryNames() = Column { + val libraries = listOf( + R.string.androidplot, + R.string.apache_commons_csv, + R.string.three_ten_android_backport, + R.string.timber, + R.string.moshi + ) + + for (library in libraries) { + TextBody1( + text = stringResource(id = library), + maxLines = 1 + ) + } +} + +@Composable +fun LibraryVersions() = Column { + val versions = listOf( + R.string.apache_2_0, + R.string.apache_2_0, + R.string.apache_2_0, + R.string.apache_2_0, + R.string.apache_2_0, + ) + + for (version in versions) { + TextBody1( + text = stringResource(id = version), + maxLines = 1 + ) + } +} + +@Composable +fun AboutLibraries() = TextBody1( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.about_libraries_message), + textAlign = TextAlign.Center, +) + +@Composable +fun RepoLink( + onLinkClicked: () -> Unit = {} +) = TextLink( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.github_link_friendly), + textAlign = TextAlign.Center, + onClick = onLinkClicked +) + +@Composable +fun Headline() = TextSubtitle2( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.about_description), + textAlign = TextAlign.Center +) + +@Composable +private fun TnGIcon(modifier: Modifier) = Card( + modifier = modifier.size(80.dp), + shape = MaterialTheme.shapes.medium, +) { + AndroidView( + modifier = modifier, + factory = { ctx -> + ImageView(ctx).apply { + setImageResource(R.drawable.app_icon) + } + } + ) +} + +@Composable +private fun VersionText(versionText: String) = Box( + modifier = Modifier.fillMaxWidth() +) { + TextBody1( + modifier = Modifier.align(Alignment.CenterEnd), + versionText + ) +} + + +@Preview(showBackground = true) +@Composable +fun AboutPageViewPreview() { + TnGComposeTheme { + AboutPageView("v1.0.0") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/adddatapoint/AddDataPointTutorialView.kt b/app/src/main/java/com/samco/trackandgraph/adddatapoint/AddDataPointTutorialView.kt new file mode 100644 index 000000000..522ef80be --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/adddatapoint/AddDataPointTutorialView.kt @@ -0,0 +1,292 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +@file:OptIn(ExperimentalPagerApi::class) + +package com.samco.trackandgraph.adddatapoint + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import com.google.accompanist.pager.ExperimentalPagerApi +import com.google.accompanist.pager.HorizontalPager +import com.google.accompanist.pager.rememberPagerState +import com.samco.trackandgraph.R +import com.samco.trackandgraph.ui.compose.theming.tngColors +import com.samco.trackandgraph.ui.compose.ui.* +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import org.threeten.bp.OffsetDateTime + +@Composable +fun AddDataPointsTutorial(viewModel: AddDataPointTutorialViewModel) = Column( + modifier = Modifier.fillMaxSize() +) { + val currentPage by viewModel.currentPage.observeAsState(0) + val pagerState = rememberPagerState(initialPage = currentPage) + + HorizontalPager( + modifier = Modifier.weight(1f), + count = 3, + state = pagerState + ) { page -> + FadingScrollColumn(modifier = Modifier.fillMaxSize()) { + when (page) { + 0 -> TutorialPage0() + 1 -> TutorialPage1() + 2 -> TutorialPage2() + } + } + } + + val buttonText = + if (currentPage == 2) stringResource(R.string.got_it) + else stringResource(R.string.next) + + //Next button + WideButton( + text = buttonText, + onClick = viewModel::onButtonClicked + ) + + //Synchronise page between view model and view: + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage }.distinctUntilChanged().collect { + viewModel.onSwipeToPage(it) + } + } + + val scope = rememberCoroutineScope() + + if (currentPage != pagerState.currentPage) { + LaunchedEffect(currentPage) { + scope.launch { + pagerState.animateScrollToPage(currentPage) + } + } + } +} + +@Composable +fun TutorialPage2() { + InputSpacingLarge() + + Text( + modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.input_spacing_large)), + text = stringResource(R.string.data_point_tutorial_page_3_description), + textAlign = TextAlign.Center, + fontSize = MaterialTheme.typography.h5.fontSize, + fontWeight = MaterialTheme.typography.h5.fontWeight + ) + + InputSpacingLarge() + + //A vector drawing of a graph + Icon( + modifier = Modifier + .fillMaxWidth() + .height(140.dp), + painter = painterResource(id = R.drawable.ic_pie_chart_example), + contentDescription = null, + tint = Color.Unspecified + ) + + InputSpacingLarge() + + Text( + modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.input_spacing_large)), + text = stringResource(R.string.data_point_tutorial_page_3_hint), + textAlign = TextAlign.Center, + fontSize = MaterialTheme.typography.body2.fontSize, + fontWeight = MaterialTheme.typography.body2.fontWeight + ) + + val context = LocalContext.current + val url = stringResource(R.string.faq_page_link_1) + Text( + modifier = Modifier + .padding( + horizontal = dimensionResource(id = R.dimen.input_spacing_large), + vertical = dimensionResource(id = R.dimen.card_padding) + ) + .fillMaxWidth() + .clickable { + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(browserIntent) + }, + text = stringResource(R.string.more_details), + color = MaterialTheme.tngColors.secondary, + textDecoration = TextDecoration.Underline, + textAlign = TextAlign.Center, + fontSize = MaterialTheme.typography.body2.fontSize, + fontWeight = MaterialTheme.typography.body2.fontWeight + ) + + InputSpacingLarge() +} + +@Composable +fun TutorialPage1() = Column( + horizontalAlignment = Alignment.CenterHorizontally +) { + InputSpacingLarge() + + Text( + modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.input_spacing_large)), + text = stringResource(R.string.data_point_tutorial_page_2_description), + textAlign = TextAlign.Center, + fontSize = MaterialTheme.typography.h5.fontSize, + fontWeight = MaterialTheme.typography.h5.fontWeight + ) + + InputSpacingLarge() + + //A vector drawing of a graph + Icon( + modifier = Modifier + .fillMaxWidth() + .height(140.dp), + painter = painterResource(id = R.drawable.ic_graph_example), + tint = MaterialTheme.tngColors.onSurface, + contentDescription = null + ) + + InputSpacingLarge() + + Text( + modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.input_spacing_large)), + text = stringResource(R.string.data_point_tutorial_page_2_hint), + textAlign = TextAlign.Center, + fontSize = MaterialTheme.typography.body2.fontSize, + fontWeight = MaterialTheme.typography.body2.fontWeight + ) + + InputSpacingLarge() +} + +@Composable +private fun TutorialPage0() { + Column( + modifier = Modifier.fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DialogInputSpacing() + + Text( + text = stringResource(R.string.adding_your_first_data_point), + textAlign = TextAlign.Center, + fontSize = MaterialTheme.typography.h4.fontSize, + fontWeight = MaterialTheme.typography.h4.fontWeight + ) + + InputSpacingLarge() + + Text( + text = stringResource(R.string.each_data_point_has_a_timestamp_and_value), + textAlign = TextAlign.Center, + fontSize = MaterialTheme.typography.subtitle2.fontSize, + fontWeight = MaterialTheme.typography.subtitle2.fontWeight, + ) + + InputSpacingLarge() + + Box( + modifier = Modifier + .height(intrinsicSize = IntrinsicSize.Max) + .scale(0.8f) + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + DateTimeButtonRow( + modifier = Modifier.fillMaxWidth(), + selectedDateTime = OffsetDateTime.now(), + onDateTimeSelected = {} + ) + + DialogInputSpacing() + + ValueInputTextField( + textFieldValue = TextFieldValue(""), + onValueChange = {} + ) + } + + //An overlay that fills the parent with a semi transparent background that consumes all click events + Box( + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.tngColors.surface.copy(alpha = 0.4f)) + .clickable(enabled = false, onClick = {}) + ) + } + + InputSpacingLarge() + + Text( + text = stringResource(R.string.it_can_also_optionally_have_a_label_and_a_note), + textAlign = TextAlign.Center, + fontSize = MaterialTheme.typography.subtitle2.fontSize, + fontWeight = MaterialTheme.typography.subtitle2.fontWeight, + ) + + DialogInputSpacing() + + Box( + modifier = Modifier + .height(intrinsicSize = IntrinsicSize.Max) + .scale(0.8f) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(id = R.dimen.input_spacing_large)), + horizontalArrangement = Arrangement.SpaceBetween + ) { + AddChipButton(text = stringResource(id = R.string.add_a_label)) { } + AddChipButton(stringResource(id = R.string.add_a_note)) {} + } + + //An overlay that fills the parent with a semi transparent background that consumes all click events + Box( + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.tngColors.surface.copy(alpha = 0.4f)) + .clickable(enabled = false, onClick = {}) + ) + } + + InputSpacingLarge() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/adddatapoint/AddDataPointTutorialViewModel.kt b/app/src/main/java/com/samco/trackandgraph/adddatapoint/AddDataPointTutorialViewModel.kt new file mode 100644 index 000000000..be977769f --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/adddatapoint/AddDataPointTutorialViewModel.kt @@ -0,0 +1,95 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.samco.trackandgraph.adddatapoint + +import androidx.lifecycle.LiveData +import androidx.lifecycle.asLiveData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext + +interface AddDataPointTutorialEvents { + val onTutorialComplete: Flow +} + +interface AddDataPointTutorialViewModel { + val currentPage: LiveData + + fun onButtonClicked() + fun onSwipeToPage(page: Int) + fun reset() +} + +class AddDataPointTutorialViewModelImpl : + AddDataPointTutorialViewModel, + AddDataPointTutorialEvents, + CoroutineScope { + + override val coroutineContext: CoroutineContext = Job() + + private sealed class PageEvent { + object NextPage : PageEvent() + object Reset : PageEvent() + data class SwipeToPage(val page: Int) : PageEvent() + } + + private val pageEvent = MutableSharedFlow(extraBufferCapacity = 1) + + private val currentPageFlow = pageEvent + .scan(0) { currentPage, event -> + when { + event is PageEvent.NextPage && currentPage == 2 -> 0 + event is PageEvent.NextPage -> currentPage + 1 + event is PageEvent.SwipeToPage -> event.page + event is PageEvent.Reset -> 0 + else -> currentPage + } + } + .onStart { emit(0) } + .stateIn(this, SharingStarted.Eagerly, 0) + + override val currentPage = currentPageFlow.asLiveData() + + override val onTutorialComplete: Flow = currentPageFlow + .filter { it == 2 } + .flatMapLatest { + //We need to stop consuming the events if the page is changed + pageEvent.takeWhile { + it is PageEvent.NextPage || (it is PageEvent.SwipeToPage && it.page == 2) + } + } + .filter { it == PageEvent.NextPage } + .map { } + .shareIn(this, SharingStarted.Eagerly, 1) + + override fun onButtonClicked() { + launch { pageEvent.emit(PageEvent.NextPage) } + } + + override fun onSwipeToPage(page: Int) { + launch { pageEvent.emit(PageEvent.SwipeToPage(page)) } + } + + override fun reset() { + launch { pageEvent.emit(PageEvent.Reset) } + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/adddatapoint/AddDataPointsView.kt b/app/src/main/java/com/samco/trackandgraph/adddatapoint/AddDataPointsView.kt new file mode 100644 index 000000000..a5dbc1842 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/adddatapoint/AddDataPointsView.kt @@ -0,0 +1,468 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +@file:OptIn( + ExperimentalPagerApi::class, ExperimentalComposeUiApi::class, + ExperimentalLayoutApi::class, ExperimentalFoundationApi::class +) + +package com.samco.trackandgraph.adddatapoint + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.google.accompanist.pager.ExperimentalPagerApi +import com.google.accompanist.pager.HorizontalPager +import com.google.accompanist.pager.rememberPagerState +import com.samco.trackandgraph.R +import com.samco.trackandgraph.ui.compose.theming.DialogTheme +import com.samco.trackandgraph.ui.compose.theming.tngColors +import com.samco.trackandgraph.ui.compose.ui.* +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import org.threeten.bp.OffsetDateTime + +@Composable +fun AddDataPointsDialog(viewModel: AddDataPointsViewModel, onDismissRequest: () -> Unit = {}) { + + val hidden by viewModel.hidden.observeAsState(true) + + LaunchedEffect(Unit) { viewModel.dismissEvents.collect { onDismissRequest() } } + + if (!hidden) { + DialogTheme { + Dialog( + onDismissRequest = { onDismissRequest() }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false + ) + ) { + AddDataPointsView( + modifier = Modifier.fillMaxWidth(0.9f), + viewModel = viewModel + ) + BackHandler { + if (viewModel.showCancelConfirmDialog.value == true) { + viewModel.onConfirmCancelDismissed() + } else viewModel.onCancelClicked() + } + } + } + } +} + +@Composable +private fun AddDataPointsView( + modifier: Modifier = Modifier, + viewModel: AddDataPointsViewModel +) = Surface { + Column( + modifier = modifier + .heightIn(max = 400.dp) + .background(color = MaterialTheme.tngColors.surface) + .padding(dimensionResource(id = R.dimen.card_padding)) + ) { + val showTutorial by viewModel.showTutorial.observeAsState(false) + if (showTutorial) AddDataPointsTutorial(viewModel.tutorialViewModel) + else DataPointInputView(viewModel) + } + + if (viewModel.showCancelConfirmDialog.observeAsState(false).value) { + ConfirmCancelDialog( + body = R.string.confirm_cancel_notes_will_be_lost, + onDismissRequest = viewModel::onConfirmCancelDismissed, + onConfirm = viewModel::onConfirmCancelConfirmed, + ) + } +} + +@Composable +private fun ColumnScope.DataPointInputView(viewModel: AddDataPointsViewModel) { + HintHeader(viewModel) + + TrackerPager(Modifier.weight(1f, true), viewModel) + + BottomButtons(viewModel) +} + +@Composable +private fun BottomButtons(viewModel: AddDataPointsViewModel) { + val focusManager = LocalFocusManager.current + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + SmallTextButton( + stringRes = R.string.cancel, + onClick = viewModel::onCancelClicked, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.tngColors.onSurface + ) + ) + if (viewModel.skipButtonVisible.observeAsState(false).value) { + SmallTextButton( + stringRes = R.string.skip, + onClick = { + focusManager.clearFocus() + viewModel.onSkipClicked() + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.tngColors.onSurface + ) + ) + } + val addButtonRes = + if (viewModel.updateMode.observeAsState(false).value) R.string.update + else R.string.add + SmallTextButton(stringRes = addButtonRes, onClick = { + focusManager.clearFocus() + viewModel.onAddClicked() + }) + + } +} + +@Composable +private fun TrackerPager(modifier: Modifier, viewModel: AddDataPointsViewModel) { + val count by viewModel.dataPointPages.observeAsState(0) + val pagerState = rememberPagerState(initialPage = viewModel.currentPageIndex.value ?: 0) + val focusManager = LocalFocusManager.current + + HorizontalPager( + modifier = modifier, + count = count, + state = pagerState + ) { page -> + viewModel.getViewModel(page).observeAsState().value?.let { + TrackerPage( + viewModel = it, + currentPage = page == pagerState.currentPage + ) + } + } + + //Synchronise page between view model and view: + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage }.distinctUntilChanged().collect { + focusManager.clearFocus() + viewModel.updateCurrentPage(it) + } + } + + val currentPage by viewModel.currentPageIndex.observeAsState(0) + val scope = rememberCoroutineScope() + + LaunchedEffect(currentPage) { + if (currentPage != pagerState.currentPage) { + scope.launch { + pagerState.animateScrollToPage(currentPage) + } + } + } +} + +@Composable +private fun TrackerPage( + viewModel: AddDataPointViewModel, + currentPage: Boolean +) { + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .padding(horizontal = 2.dp) + .verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + + val focusManager = LocalFocusManager.current + val valueFocusRequester = remember { FocusRequester() } + + TrackerNameHeadline(name = viewModel.name.observeAsState("").value) + + DialogInputSpacing() + + val selectedDateTime by viewModel.timestamp.observeAsState(OffsetDateTime.now()) + + DateTimeButtonRow( + modifier = Modifier.fillMaxWidth(), + selectedDateTime = selectedDateTime, + onDateTimeSelected = viewModel::updateTimestamp + ) + + DialogInputSpacing() + + val suggestedValues by viewModel.suggestedValues.observeAsState(emptyList()) + val selectedSuggestedValue by viewModel.currentValueAsSuggestion.observeAsState() + + if (suggestedValues.isNotEmpty()) { + SuggestedValues( + suggestedValues, + selectedSuggestedValue, + viewModel::onSuggestedValueSelected, + viewModel::onSuggestedValueLongPress + ) + DialogInputSpacing() + } + + LaunchedEffect(valueFocusRequester) { + viewModel.focusOnValueEvent.collect { + delay(100) + valueFocusRequester.requestFocus() + } + } + + var labelAdded by rememberSaveable { mutableStateOf(false) } + var noteAdded by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(viewModel) { + snapshotFlow { viewModel.label.text } + .dropWhile { it.isEmpty() } + .take(1) + .collect { labelAdded = true } + } + + LaunchedEffect(viewModel) { + snapshotFlow { viewModel.note.text } + .dropWhile { it.isEmpty() } + .take(1) + .collect { noteAdded = true } + } + + when (viewModel) { + is AddDataPointViewModel.NumericalDataPointViewModel -> { + ValueInputTextField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(id = R.dimen.input_spacing_large)), + textFieldValue = viewModel.value, + onValueChange = viewModel::setValueText, + focusManager = focusManager, + focusRequester = valueFocusRequester, + onNextOverride = { + if (labelAdded || noteAdded) focusManager.moveFocus(FocusDirection.Down) + else viewModel.addDataPoint() + } + ) + } + + is AddDataPointViewModel.DurationDataPointViewModel -> { + DurationInput( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(id = R.dimen.input_spacing_large)), + viewModel = viewModel, + focusManager = focusManager, + nextFocusDirection = FocusDirection.Down, + focusRequester = valueFocusRequester + ) + } + + else -> {} + } + + LaunchedEffect(currentPage) { + delay(100) + if (currentPage && suggestedValues.all { it.value == null }) + valueFocusRequester.requestFocus() + } + + LabelAndNoteInputs( + viewModel = viewModel, + scrollState = scrollState, + labelAdded = labelAdded, + noteAdded = noteAdded, + onLabelAdded = { labelAdded = true }, + onNoteAdded = { noteAdded = true } + ) + } +} + +@Composable +private fun LabelAndNoteInputs( + viewModel: AddDataPointViewModel, + scrollState: ScrollState, + labelAdded: Boolean, + noteAdded: Boolean, + onLabelAdded: () -> Unit, + onNoteAdded: () -> Unit +) { + val focusManager = LocalFocusManager.current + + var noteBoxHeight by remember { mutableStateOf(null) } + + LaunchedEffect(noteBoxHeight) { + if (noteBoxHeight != null) scrollState.scrollTo(scrollState.maxValue) + } + + val coroutineScope = rememberCoroutineScope() + val noteInputFocusRequester = remember { FocusRequester() } + val labelInputFocusRequester = remember { FocusRequester() } + + DialogInputSpacing() + + val bringIntoViewRequester = remember { BringIntoViewRequester() } + + if (labelAdded) { + LabelInputTextField( + modifier = Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester) + .padding(horizontal = dimensionResource(id = R.dimen.input_spacing_large)) + .onFocusEvent { state -> + if (state.isFocused) { + coroutineScope.launch { bringIntoViewRequester.bringIntoView() } + } + }, + textFieldValue = viewModel.label, + onValueChange = viewModel::updateLabel, + focusManager = focusManager, + focusRequester = labelInputFocusRequester, + onNextOverride = { + if (noteAdded) focusManager.moveFocus(FocusDirection.Down) + else viewModel.addDataPoint() + } + ) + } + + if (noteAdded) { + if (labelAdded) DialogInputSpacing() + FullWidthTextField( + modifier = Modifier + .heightIn(max = 200.dp) + .padding(horizontal = dimensionResource(id = R.dimen.input_spacing_large)) + .onGloballyPositioned { + if (noteBoxHeight != it.size.height) noteBoxHeight = it.size.height + }, + textFieldValue = viewModel.note, + onValueChange = { viewModel.updateNote(it) }, + focusRequester = noteInputFocusRequester, + label = stringResource(id = R.string.note_input_hint), + singleLine = false + ) + } + + if (labelAdded xor noteAdded) DialogInputSpacing() + + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(id = R.dimen.input_spacing_large)), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + if (!labelAdded) { + AddChipButton(text = stringResource(id = R.string.add_a_label)) { + onLabelAdded() + coroutineScope.launch { + delay(50) + labelInputFocusRequester.requestFocus() + } + } + } + if (!noteAdded) { + AddChipButton(text = stringResource(id = R.string.add_a_note)) { + onNoteAdded() + coroutineScope.launch { + delay(50) + noteInputFocusRequester.requestFocus() + } + } + } + } +} + +@Composable +private fun HintHeader(viewModel: AddDataPointsViewModel) = + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = viewModel.indexText.observeAsState("").value, + fontSize = MaterialTheme.typography.body1.fontSize, + fontWeight = MaterialTheme.typography.body1.fontWeight, + ) + //Faq vecotor icon as a button + IconButton(onClick = { viewModel.onTutorialButtonPressed() }) { + Icon( + painter = painterResource(id = R.drawable.faq_icon), + contentDescription = stringResource(id = R.string.help), + tint = MaterialTheme.colors.onSurface + ) + } + } + +@Composable +private fun SuggestedValues( + list: List, + selectedItem: SuggestedValueViewData?, + onSuggestedValueSelected: (SuggestedValueViewData) -> Unit, + onSuggestedValueLongPress: (SuggestedValueViewData) -> Unit +) { + val focusManager = LocalFocusManager.current + FadingLazyRow( + horizontalArrangement = Arrangement.spacedBy( + dimensionResource(id = R.dimen.dialog_input_spacing), + Alignment.CenterHorizontally + ) + ) { + items(count = list.size, itemContent = { index -> + val suggestedValue = list[index] + + //We should not be passed values with null for everything + val text: String = + if (suggestedValue.label.isNullOrBlank() && suggestedValue.valueStr != null) suggestedValue.valueStr + else if (suggestedValue.valueStr.isNullOrBlank() && suggestedValue.label != null) suggestedValue.label + else "${suggestedValue.valueStr} : ${suggestedValue.label}" + + TextChip( + text = text, + isSelected = suggestedValue == selectedItem, + onClick = { + focusManager.clearFocus() + onSuggestedValueSelected(suggestedValue) + }, + onLongPress = { + onSuggestedValueLongPress(suggestedValue) + } + ) + }) + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/adddatapoint/AddDataPointsViewModel.kt b/app/src/main/java/com/samco/trackandgraph/adddatapoint/AddDataPointsViewModel.kt new file mode 100644 index 000000000..d0a5fec34 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/adddatapoint/AddDataPointsViewModel.kt @@ -0,0 +1,542 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.samco.trackandgraph.adddatapoint + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.* +import com.samco.trackandgraph.base.database.dto.DataPoint +import com.samco.trackandgraph.base.database.dto.DataType +import com.samco.trackandgraph.base.database.dto.Tracker +import com.samco.trackandgraph.base.database.dto.TrackerSuggestionType +import com.samco.trackandgraph.base.helpers.PrefHelper +import com.samco.trackandgraph.base.helpers.doubleFormatter +import com.samco.trackandgraph.base.helpers.formatTimeDuration +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.ui.viewmodels.DurationInputViewModel +import com.samco.trackandgraph.ui.viewmodels.DurationInputViewModelImpl +import com.samco.trackandgraph.util.getDoubleFromTextOrNull +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import org.threeten.bp.OffsetDateTime +import javax.inject.Inject + +data class SuggestedValueViewData( + val value: Double?, + val valueStr: String?, + val label: String? +) + +sealed interface AddDataPointViewModel { + val name: LiveData + val timestamp: LiveData + val label: TextFieldValue + val note: TextFieldValue + val suggestedValues: LiveData> + val currentValueAsSuggestion: LiveData + + val focusOnValueEvent: Flow + + val oldDataPoint: DataPoint? + + fun getTracker(): Tracker + fun updateLabel(label: TextFieldValue) + fun updateNote(note: TextFieldValue) + fun updateTimestamp(timestamp: OffsetDateTime) + fun addDataPoint() + fun onSuggestedValueSelected(suggestedValue: SuggestedValueViewData) + fun onSuggestedValueLongPress(suggestedValue: SuggestedValueViewData) + + interface NumericalDataPointViewModel : AddDataPointViewModel { + val value: TextFieldValue + fun setValueText(value: TextFieldValue) + } + + interface DurationDataPointViewModel : AddDataPointViewModel, DurationInputViewModel +} + +interface AddDataPointsViewModel { + + val hidden: LiveData + val showTutorial: LiveData + val updateMode: LiveData + val indexText: LiveData + val skipButtonVisible: LiveData + val dataPointPages: LiveData + val currentPageIndex: LiveData + val tutorialViewModel: AddDataPointTutorialViewModel + val showCancelConfirmDialog: LiveData + val dismissEvents: Flow + + fun getViewModel(pageIndex: Int): LiveData + + fun onTutorialButtonPressed() + + fun onCancelClicked() + fun onConfirmCancelConfirmed() + fun onConfirmCancelDismissed() + fun onSkipClicked() + fun onAddClicked() + fun updateCurrentPage(page: Int) +} + +interface AddDataPointsNavigationViewModel : AddDataPointsViewModel { + + fun showAddDataPointDialog( + trackerId: Long, + dataPointTimestamp: OffsetDateTime? = null, + customInitialValue: Double? = null + ) + + fun showAddDataPointsDialog( + trackerIds: List, + dataPointTimestamp: OffsetDateTime? = null, + customInitialValue: Double? = null + ) + + fun reset() +} + +@HiltViewModel +class AddDataPointsViewModelImpl @Inject constructor( + private val dataInteractor: DataInteractor, + private val suggestedValueHelper: SuggestedValueHelper, + @IODispatcher private val io: CoroutineDispatcher, + private val prefHelper: PrefHelper +) : ViewModel(), AddDataPointsNavigationViewModel { + + private interface AddDataPointViewModelInner : AddDataPointViewModel { + var tracked: Boolean + fun getDouble(): Double + } + + private data class Config( + val tracker: Tracker, + val timestamp: OffsetDateTime?, + val label: String?, + val value: Double?, + val note: String?, + val oldDataPoint: DataPoint? + ) + + private val configFlow = MutableStateFlow>(emptyList()) + private val indexFlow = MutableStateFlow(0) + override val hidden = MutableLiveData(true) + override val dismissEvents = MutableSharedFlow() + private val tutorialButtonPresses = MutableSharedFlow() + + override val tutorialViewModel = AddDataPointTutorialViewModelImpl() + + override val showCancelConfirmDialog = MutableLiveData(false) + + //Show the tutorial if the user has no data or if they have pressed the tutorial button + override val showTutorial: LiveData = merge( + tutorialButtonPresses + .map { true } + .onStart { + val hasData = dataInteractor.hasAtLeastOneDataPoint() + emit(!hasData && !prefHelper.getHideDataPointTutorial()) + }, + tutorialViewModel.onTutorialComplete.map { false }, + dismissEvents.map { false } + ) + .shareIn(viewModelScope, SharingStarted.Eagerly, 1) + .asLiveData(viewModelScope.coroutineContext) + + init { + viewModelScope.launch { + tutorialViewModel.onTutorialComplete.collect { + prefHelper.setHideDataPointTutorial(true) + } + } + } + + override fun onTutorialButtonPressed() { + viewModelScope.launch { tutorialButtonPresses.emit(Unit) } + } + + override val dataPointPages = configFlow + .map { it.size } + .distinctUntilChanged() + .asLiveData(viewModelScope.coroutineContext) + + override val currentPageIndex = indexFlow + .asLiveData(viewModelScope.coroutineContext) + + private val viewModels: SharedFlow> = configFlow + .map { it.map { config -> createViewModel(config) } } + .shareIn(viewModelScope, SharingStarted.Eagerly, 1) + + override fun getViewModel(pageIndex: Int): LiveData = viewModels + .map { it.getOrNull(pageIndex) } + .filterNotNull() + .asLiveData(viewModelScope.coroutineContext) + + private fun createViewModel(config: Config): AddDataPointViewModelInner { + return when (config.tracker.dataType) { + DataType.DURATION -> DataPointDurationViewModel(config) + DataType.CONTINUOUS -> getNumericalViewModel(config) + } + } + + private val now get() = OffsetDateTime.now() + + private val lastSelectedTimestampGlobal = MutableSharedFlow() + + private abstract inner class AddDataPointBaseViewModelImpl( + protected val config: Config + ) : AddDataPointViewModelInner { + + override var tracked = false + override val oldDataPoint = config.oldDataPoint + override val name = MutableLiveData(config.tracker.name) + + override val focusOnValueEvent = MutableSharedFlow() + + private val onTimestampSelected = MutableSharedFlow() + override val timestamp = merge( + onTimestampSelected, + lastSelectedTimestampGlobal.takeWhile { !tracked } + ) + .onStart { emit(config.timestamp ?: now) } + .shareIn(viewModelScope, SharingStarted.Eagerly, 1) + .asLiveData(viewModelScope.coroutineContext) + + override var label by mutableStateOf(TextFieldValue(config.label ?: "")) + override var note by mutableStateOf(TextFieldValue(config.note ?: "")) + + override val suggestedValues: LiveData> = suggestedValueHelper + .getSuggestedValues(config.tracker) + .map { list -> + list.map { + SuggestedValueViewData( + it.value, + getValueString(it.value, config.tracker.dataType.isDuration()), + it.label + ) + } + } + .flowOn(io) + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + .asLiveData(viewModelScope.coroutineContext) + + protected val onUpdateCurrentSuggestion = MutableSharedFlow() + + override val currentValueAsSuggestion: LiveData = + onUpdateCurrentSuggestion + .map { getAsSuggestedValue(getDouble(), label.text) } + .onStart { + config.oldDataPoint?.let { emit(getAsSuggestedValue(it.value, it.label)) } + } + .asLiveData(viewModelScope.coroutineContext) + + private fun getAsSuggestedValue(value: Double, label: String): SuggestedValueViewData? { + return when (config.tracker.suggestionType) { + TrackerSuggestionType.NONE -> null + TrackerSuggestionType.LABEL_ONLY -> SuggestedValueViewData( + value = null, + valueStr = null, + label = label + ) + TrackerSuggestionType.VALUE_ONLY -> SuggestedValueViewData( + value = value, + valueStr = getValueString(value, config.tracker.dataType.isDuration()), + label = null + ) + TrackerSuggestionType.VALUE_AND_LABEL -> SuggestedValueViewData( + value = value, + valueStr = getValueString(value, config.tracker.dataType.isDuration()), + label = label.ifEmpty { null } + ) + } + } + + private fun getValueString(value: Double?, isDuration: Boolean): String? = when { + value == null -> null + isDuration -> formatTimeDuration(value.toLong()) + else -> doubleFormatter.format(value) + } + + override fun getTracker() = config.tracker + + override fun addDataPoint() = onAddClicked() + + override fun updateLabel(label: TextFieldValue) { + this.label = label + viewModelScope.launch { onUpdateCurrentSuggestion.emit(Unit) } + } + + override fun updateNote(note: TextFieldValue) { + this.note = note + } + + override fun updateTimestamp(timestamp: OffsetDateTime) { + viewModelScope.launch { + lastSelectedTimestampGlobal.emit(timestamp) + onTimestampSelected.emit(timestamp) + } + } + + override fun onSuggestedValueLongPress(suggestedValue: SuggestedValueViewData) { + setLabelFromSuggestedValue(suggestedValue) + viewModelScope.launch { onUpdateCurrentSuggestion.emit(Unit) } + if (suggestedValue.value == null) onAddClicked() + else viewModelScope.launch { focusOnValueEvent.emit(Unit) } + } + + override fun onSuggestedValueSelected(suggestedValue: SuggestedValueViewData) { + setLabelFromSuggestedValue(suggestedValue) + viewModelScope.launch { onUpdateCurrentSuggestion.emit(Unit) } + if (suggestedValue.value != null) onAddClicked() + else viewModelScope.launch { focusOnValueEvent.emit(Unit) } + } + + private fun setLabelFromSuggestedValue(suggestedValue: SuggestedValueViewData) { + suggestedValue.label?.let { this.label = TextFieldValue(it, TextRange(it.length)) } + } + } + + private fun getNumericalViewModel(config: Config) = + object : AddDataPointViewModel.NumericalDataPointViewModel, + AddDataPointBaseViewModelImpl(config) { + + private val initialValue = config.value?.toString() ?: "" + override var value by mutableStateOf( + TextFieldValue( + initialValue, + TextRange(initialValue.length) + ) + ) + + override fun setValueText(value: TextFieldValue) { + this.value = value + viewModelScope.launch { onUpdateCurrentSuggestion.emit(Unit) } + } + + override fun onSuggestedValueSelected(suggestedValue: SuggestedValueViewData) { + setValueFromSuggestedValue(suggestedValue) + super.onSuggestedValueSelected(suggestedValue) + } + + override fun getDouble(): Double = getDoubleFromTextOrNull(value.text) ?: 1.0 + + override fun onSuggestedValueLongPress(suggestedValue: SuggestedValueViewData) { + setValueFromSuggestedValue(suggestedValue) + super.onSuggestedValueLongPress(suggestedValue) + } + + private fun setValueFromSuggestedValue(suggestedValue: SuggestedValueViewData) { + suggestedValue.value?.let { + this.value = TextFieldValue(it.toString(), TextRange(0, it.toString().length)) + } + } + } + + private inner class DataPointDurationViewModel( + config: Config, + private val durationInputViewModel: DurationInputViewModel = DurationInputViewModelImpl() + ) : + DurationInputViewModel by durationInputViewModel, + AddDataPointBaseViewModelImpl(config), + AddDataPointViewModel.DurationDataPointViewModel { + + init { + durationInputViewModel.setDurationFromDouble(config.value ?: 0.0) + } + + override fun setHoursText(value: TextFieldValue) { + durationInputViewModel.setHoursText(value) + viewModelScope.launch { onUpdateCurrentSuggestion.emit(Unit) } + } + + override fun setMinutesText(value: TextFieldValue) { + durationInputViewModel.setMinutesText(value) + viewModelScope.launch { onUpdateCurrentSuggestion.emit(Unit) } + } + + override fun setSecondsText(value: TextFieldValue) { + durationInputViewModel.setSecondsText(value) + viewModelScope.launch { onUpdateCurrentSuggestion.emit(Unit) } + } + + override fun onSuggestedValueSelected(suggestedValue: SuggestedValueViewData) { + suggestedValue.value?.let { this.setDurationFromDouble(it) } + super.onSuggestedValueSelected(suggestedValue) + } + + override fun getDouble(): Double = getDurationAsDouble() + + override fun onSuggestedValueLongPress(suggestedValue: SuggestedValueViewData) { + suggestedValue.value?.let { this.setDurationFromDouble(it) } + super.onSuggestedValueLongPress(suggestedValue) + } + } + + override val updateMode: LiveData = configFlow.map { + it.size == 1 && it[0].timestamp != null + }.asLiveData(viewModelScope.coroutineContext) + + override val indexText = combine(indexFlow, configFlow) { index, configs -> + if (configs.size == 1) "" + else "${index + 1} / ${configs.size}" + }.asLiveData(viewModelScope.coroutineContext) + + override val skipButtonVisible = combine(indexFlow, configFlow) { index, configs -> + if (configs.size == 1) false + else index < configs.size + }.asLiveData(viewModelScope.coroutineContext) + + private var initialized = false + + override fun onCancelClicked() { + onCurrentViewModel { + if (it.note.text.isNotEmpty() && this.showCancelConfirmDialog.value == false) + this.showCancelConfirmDialog.value = true + else dismiss() + } + } + + override fun onConfirmCancelConfirmed() = dismiss() + + override fun onConfirmCancelDismissed() { + showCancelConfirmDialog.value = false + } + + private fun onCurrentViewModel(action: suspend (AddDataPointViewModelInner) -> Unit) = + viewModelScope.launch { + combine(indexFlow, viewModels) { index, viewModels -> viewModels.getOrNull(index) } + .take(1) + .filterNotNull() + .collect { action(it) } + } + + override fun onSkipClicked() = incrementPageIndex() + + override fun onAddClicked() { + onCurrentViewModel { insertDataPoint(it) } + } + + private suspend fun insertDataPoint(viewModel: AddDataPointViewModelInner) { + viewModel.oldDataPoint?.let { dataInteractor.deleteDataPoint(it) } + getDataPoint(viewModel)?.let { newDataPoint -> + dataInteractor.insertDataPoint(newDataPoint) + incrementPageIndex() + } + viewModel.tracked = true + } + + private fun getDataPoint(viewModel: AddDataPointViewModelInner): DataPoint? { + val timestamp = viewModel.timestamp.value ?: return null + return DataPoint( + timestamp = timestamp, + featureId = viewModel.getTracker().featureId, + value = viewModel.getDouble(), + label = viewModel.label.text, + note = viewModel.note.text + ) + } + + override fun updateCurrentPage(page: Int) { + setPageIndex(page) + } + + private fun incrementPageIndex() { + if (!setPageIndex(indexFlow.value + 1)) dismiss() + } + + private fun setPageIndex(index: Int): Boolean { + return if (index >= 0 && index < configFlow.value.size) { + indexFlow.value = index + true + } else false + } + + override fun showAddDataPointDialog( + trackerId: Long, + dataPointTimestamp: OffsetDateTime?, + customInitialValue: Double? + ) = showAddDataPointsDialog(listOf(trackerId), dataPointTimestamp, customInitialValue) + + override fun showAddDataPointsDialog( + trackerIds: List, + dataPointTimestamp: OffsetDateTime?, + customInitialValue: Double? + ) { + if (initialized) return + initialized = true + hidden.value = false + + viewModelScope.launch(io) { + val configs = trackerIds + .mapNotNull { dataInteractor.getTrackerById(it) } + .map { getConfig(it, dataPointTimestamp, customInitialValue) } + + if (configs.isEmpty()) dismiss() + else configFlow.emit(configs) + } + } + + private suspend fun getConfig( + tracker: Tracker, + dataPointTimestamp: OffsetDateTime?, + customInitialValue: Double? + ): Config { + val dataPoint = dataPointTimestamp?.let { timestamp -> + dataInteractor.getDataPointByTimestampAndTrackerSync( + tracker.id, + timestamp + ) + } + return Config( + tracker = tracker, + timestamp = dataPointTimestamp, + label = dataPoint?.label, + value = customInitialValue ?: dataPoint?.value, + note = dataPoint?.note, + oldDataPoint = dataPoint + ) + } + + private fun dismiss() { + hidden.value = true + viewModelScope.launch { dismissEvents.emit(Unit) } + } + + override fun reset() { + showCancelConfirmDialog.value = false + indexFlow.value = 0 + lastSelectedTimestampGlobal.resetReplayCache() + initialized = false + tutorialViewModel.reset() + configFlow.value = emptyList() + } + + override fun onCleared() { + super.onCleared() + tutorialViewModel.cancel() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/adddatapoint/SuggestedValueHelper.kt b/app/src/main/java/com/samco/trackandgraph/adddatapoint/SuggestedValueHelper.kt new file mode 100644 index 000000000..44e74f931 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/adddatapoint/SuggestedValueHelper.kt @@ -0,0 +1,120 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.adddatapoint + +import com.samco.trackandgraph.base.database.dto.IDataPoint +import com.samco.trackandgraph.base.database.dto.Tracker +import com.samco.trackandgraph.base.database.dto.TrackerSuggestionOrder +import com.samco.trackandgraph.base.database.dto.TrackerSuggestionType +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.IODispatcher +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +data class SuggestedValue( + val value: Double?, + val label: String? +) + +interface SuggestedValueHelper { + fun getSuggestedValues(tracker: Tracker): Flow> +} + +@OptIn(ExperimentalCoroutinesApi::class) +class SuggestedValueHelperImpl @Inject constructor( + private val dataInteractor: DataInteractor, + @IODispatcher private val io: CoroutineDispatcher +) : SuggestedValueHelper { + + companion object { + const val MAX_VALUES = 1000 + } + + private fun getDataPoints(tracker: Tracker): Flow = flow { + val sample = dataInteractor.getDataSampleForFeatureId(tracker.featureId) + emit( + flow { for (dp in sample) emit(dp) } + .onCompletion { sample.dispose() } + ) + }.flatMapLatest { it } + + override fun getSuggestedValues(tracker: Tracker): Flow> = + getDataPoints(tracker) + .take(MAX_VALUES) + .onCompletion { } + .map { SuggestedValue(it.value, it.label.ifEmpty { null }) } + .scan(emptyList()) { acc, value -> acc + value } + .buffer(1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + .sort(tracker) + .mapToSuggestionType(tracker) + .map { it.distinct() } + .buffer(1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + .flowOn(io) + + private val emptySuggestion = SuggestedValue(null, null) + + private fun Flow>.mapToSuggestionType(tracker: Tracker) = + when (tracker.suggestionType) { + TrackerSuggestionType.VALUE_AND_LABEL -> this + TrackerSuggestionType.VALUE_ONLY -> this.map { list -> list.map { it.copy(label = null) } } + TrackerSuggestionType.LABEL_ONLY -> this.map { list -> list.map { it.copy(value = null) } } + TrackerSuggestionType.NONE -> this.map { emptyList() } + }.map { list -> list.filter { it != emptySuggestion } } + + private fun Flow>.sort(tracker: Tracker): Flow> { + return if (tracker.suggestionType == TrackerSuggestionType.NONE) this.map { emptyList() } + else when (tracker.suggestionOrder) { + TrackerSuggestionOrder.VALUE_ASCENDING -> this.map { values -> values.sortedBy { it.value } } + TrackerSuggestionOrder.VALUE_DESCENDING -> this.map { values -> values.sortedByDescending { it.value } } + TrackerSuggestionOrder.LABEL_ASCENDING -> this.map { values -> + values.sortedWith( + compareBy(stringComparatorWithEmpty) { it.label } + ) + } + TrackerSuggestionOrder.LABEL_DESCENDING -> this.map { values -> + values.sortedWith( + compareBy(stringComparatorReversedWithEmpty) { it.label } + ) + } + TrackerSuggestionOrder.LATEST -> this + TrackerSuggestionOrder.OLDEST -> this.map { values -> values.reversed() } + } + + } + + private val stringComparatorWithEmpty = Comparator { s1, s2 -> + return@Comparator checkEmptys(s1, s2) ?: s1!!.compareTo(s2!!) + } + + private val stringComparatorReversedWithEmpty = Comparator { s1, s2 -> + return@Comparator checkEmptys(s1, s2) ?: s2!!.compareTo(s1!!) + } + + //The common code from the above two functions is extracted into a separate function + private fun checkEmptys(s1: String?, s2: String?): Int? { + if (s1 == null && s2 == null) return 0 + if (s1 == null) return 1 + if (s2 == null) return -1 + if (s1.isEmpty() && s2.isEmpty()) return 0 + if (s1.isEmpty()) return 1 + if (s2.isEmpty()) return -1 + return null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/addgroup/AddGroupDialog.kt b/app/src/main/java/com/samco/trackandgraph/addgroup/AddGroupDialog.kt new file mode 100644 index 000000000..268a884ab --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/addgroup/AddGroupDialog.kt @@ -0,0 +1,98 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.addgroup + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.samco.trackandgraph.R +import com.samco.trackandgraph.ui.compose.theming.DialogTheme +import com.samco.trackandgraph.ui.compose.ui.ColorSpinner +import com.samco.trackandgraph.ui.compose.ui.CustomConfirmCancelDialog +import com.samco.trackandgraph.ui.compose.ui.InputSpacingLarge + +@Composable +fun AddGroupDialog(viewModel: AddGroupDialogViewModel, onDismissRequest: () -> Unit = {}) { + if (!viewModel.hidden) { + DialogTheme { + + val addEnabled = viewModel.addEnabled.collectAsStateWithLifecycle().value + val updateMode = viewModel.updateMode.collectAsStateWithLifecycle().value + + CustomConfirmCancelDialog( + onDismissRequest = { onDismissRequest() }, + onConfirm = { + viewModel.addOrUpdateGroup() + onDismissRequest() + }, + customWidthPercentage = 0.9f, + continueText = if (updateMode) R.string.update else R.string.add, + dismissText = R.string.cancel, + continueEnabled = addEnabled, + ) { + AddGroupView( + modifier = Modifier.fillMaxWidth(), + viewModel = viewModel + ) + } + } + } +} + +@Composable +private fun AddGroupView(modifier: Modifier, viewModel: AddGroupDialogViewModel) = + Column(modifier) { + Text( + text = stringResource(id = R.string.add_group), + style = MaterialTheme.typography.h6 + ) + + InputSpacingLarge() + + val focusRequester = remember { FocusRequester() } + + Row(modifier = Modifier.fillMaxWidth()) { + ColorSpinner( + selectedColor = viewModel.colorIndex, + onColorSelected = viewModel::updateColorIndex, + ) + + OutlinedTextField( + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester), + value = viewModel.name, + onValueChange = viewModel::updateName + ) + } + + LaunchedEffect(viewModel.hidden) { + if (!viewModel.hidden) focusRequester.requestFocus() + } + } \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/addgroup/AddGroupDialogViewModel.kt b/app/src/main/java/com/samco/trackandgraph/addgroup/AddGroupDialogViewModel.kt new file mode 100644 index 000000000..048f66086 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/addgroup/AddGroupDialogViewModel.kt @@ -0,0 +1,147 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.addgroup + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.TextRange +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.samco.trackandgraph.base.database.dto.Group +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.ui.dataVisColorList +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +interface AddGroupDialogViewModel { + val colorIndex: Int + val name: TextFieldValue + val hidden: Boolean + val loading: Boolean + + val updateMode: StateFlow + val addEnabled: StateFlow + + fun show(parentGroupId: Long?, groupId: Long?) + fun addOrUpdateGroup() + fun hide() + fun updateColorIndex(index: Int) + fun updateName(name: TextFieldValue) +} + +@HiltViewModel +class AddGroupDialogViewModelImpl @Inject constructor( + private val dataInteractor: DataInteractor +) : ViewModel(), AddGroupDialogViewModel { + override var colorIndex: Int by mutableStateOf(0) + private set + + override var name: TextFieldValue by mutableStateOf(TextFieldValue()) + private set + + override var hidden: Boolean by mutableStateOf(true) + private set + + override var loading: Boolean by mutableStateOf(false) + private set + + private var parentGroupId: Long? = null + private var currentGroup = MutableStateFlow(null) + + override val addEnabled: StateFlow = combine( + snapshotFlow { colorIndex }, + snapshotFlow { name } + ) { colorIndex, name -> + colorIndex in dataVisColorList.indices && name.text.isNotBlank() + }.stateIn(viewModelScope, SharingStarted.Lazily, false) + + override val updateMode: StateFlow = currentGroup + .map { it != null } + .stateIn(viewModelScope, SharingStarted.Lazily, false) + + override fun show(parentGroupId: Long?, groupId: Long?) { + colorIndex = 0 + name = TextFieldValue() + this.parentGroupId = parentGroupId + loading = false + hidden = false + if (groupId != null) { + viewModelScope.launch { + loading = true + val group = dataInteractor.getGroupById(groupId) + this@AddGroupDialogViewModelImpl.currentGroup.value = group + colorIndex = group.colorIndex + name = TextFieldValue(group.name, TextRange(group.name.length)) + loading = false + } + } else { + this.currentGroup.value = null + } + } + + override fun addOrUpdateGroup() { + if (colorIndex !in dataVisColorList.indices || name.text.isBlank()) return + val name = name.text + val colorIndex = colorIndex + viewModelScope.launch { + currentGroup.value?.let { current -> + dataInteractor.updateGroup( + Group( + id = current.id, + name = name, + displayIndex = current.displayIndex, + parentGroupId = current.parentGroupId, + colorIndex = colorIndex + ) + ) + } ?: run { + dataInteractor.insertGroup( + Group( + id = 0, + name = name, + displayIndex = 0, + parentGroupId = parentGroupId ?: 0L, + colorIndex = colorIndex + ) + ) + } + hide() + } + } + + override fun hide() { + hidden = true + } + + override fun updateColorIndex(index: Int) { + colorIndex = index + } + + override fun updateName(name: TextFieldValue) { + this.name = name + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/addtracker/AddTrackerFragment.kt b/app/src/main/java/com/samco/trackandgraph/addtracker/AddTrackerFragment.kt new file mode 100644 index 000000000..db5a2d175 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/addtracker/AddTrackerFragment.kt @@ -0,0 +1,81 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.addtracker + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.NavController +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import com.samco.trackandgraph.MainActivity +import com.samco.trackandgraph.NavButtonStyle +import com.samco.trackandgraph.R +import com.samco.trackandgraph.ui.compose.theming.TnGComposeTheme +import com.samco.trackandgraph.util.* +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class AddTrackerFragment : Fragment() { + private val args: AddTrackerFragmentArgs by navArgs() + private val viewModel by viewModels() + private var navController: NavController? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.init(args.groupId, args.editFeatureId) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + navController = container?.findNavController() + return ComposeView(requireContext()).apply { + setContent { + TnGComposeTheme { + AddTrackerView(viewModel) + } + } + } + } + + override fun onStart() { + super.onStart() + viewModel.complete.observe(viewLifecycleOwner) { + if (it) navController?.popBackStack() + } + } + + override fun onResume() { + super.onResume() + (requireActivity() as MainActivity).setActionBarConfig( + NavButtonStyle.UP, + getString(R.string.add_tracker) + ) + } + + override fun onStop() { + super.onStop() + requireActivity().window.hideKeyboard(requireActivity().currentFocus?.windowToken) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/addtracker/AddTrackerView.kt b/app/src/main/java/com/samco/trackandgraph/addtracker/AddTrackerView.kt new file mode 100644 index 000000000..853b5b0a6 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/addtracker/AddTrackerView.kt @@ -0,0 +1,392 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.addtracker + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.TrackerSuggestionOrder +import com.samco.trackandgraph.base.database.dto.TrackerSuggestionType +import com.samco.trackandgraph.base.model.TrackerHelper +import com.samco.trackandgraph.ui.compose.ui.* + +@Composable +fun AddTrackerView(viewModel: AddTrackerViewModel) { + val focusRequester = FocusRequester() + val isUpdateMode by viewModel.isUpdateMode.observeAsState(false) + val errorText by viewModel.errorText.observeAsState() + val openDialog by viewModel.showUpdateWarningAlertDialog.observeAsState(false) + + Surface(color = MaterialTheme.colors.background) { + Column(Modifier.fillMaxSize()) { + AddTrackerInputForm( + modifier = Modifier.weight(1f), + viewModel = viewModel, + focusRequester = focusRequester + ) + + AddCreateBar( + errorText = errorText, + onCreateUpdateClicked = viewModel::onCreateUpdateClicked, + isUpdateMode = isUpdateMode + ) + + if (openDialog) UpdateWarningDialog( + onDismissRequest = viewModel::onDismissUpdateWarningCancel, + onConfirm = viewModel::onConfirmUpdate + ) + + LaunchedEffect(true) { focusRequester.requestFocus() } + } + } +} + +@Composable +private fun UpdateWarningDialog( + onDismissRequest: () -> Unit, + onConfirm: () -> Unit +) = ConfirmCancelDialog( + body = R.string.ru_sure_update_data, + onDismissRequest = onDismissRequest, + onConfirm = onConfirm +) + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun AddTrackerInputForm( + modifier: Modifier, + viewModel: AddTrackerViewModel, + focusRequester: FocusRequester +) = Column( + modifier = modifier + .padding(dimensionResource(id = R.dimen.card_padding)) + .fillMaxWidth() + .verticalScroll(state = rememberScrollState()) +) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + val isDuration = viewModel.isDuration.observeAsState(false) + + DialogInputSpacing() + + NameInput( + viewModel, + focusManager, + focusRequester, + keyboardController + ) + + InputSpacingLarge() + + DescriptionInput(viewModel) + + InputSpacingLarge() + + DurationCheckbox(isDuration.value, viewModel) + + val shouldShowConversionSpinner = + viewModel.shouldShowDurationConversionModeSpinner.observeAsState(false) + val durationConversionMode = viewModel.durationNumericConversionMode.observeAsState() + + if (shouldShowConversionSpinner.value) { + InputSpacingLarge() + DurationConversionModeInput( + isDuration.value, + durationConversionMode.value, + viewModel + ) + } + + InputSpacingLarge() + + AdvancedOptions(viewModel) +} + +@Composable +private fun AdvancedOptions(viewModel: AddTrackerViewModel) = Column { + + var advancedOptionsExpanded by rememberSaveable { mutableStateOf(false) } + + AdvancedSectionHeader(advancedOptionsExpanded) { + advancedOptionsExpanded = !advancedOptionsExpanded + } + + AnimatedVisibility(visible = advancedOptionsExpanded) { + Column { + DialogInputSpacing() + + DefaultValueOptions(viewModel) + + InputSpacingLarge() + + Text( + text = stringResource(id = R.string.suggestions), + style = MaterialTheme.typography.subtitle2 + ) + + SuggestionType(viewModel) + + SuggestionOrder(viewModel) + } + } +} + +@Composable +fun SuggestionType(viewModel: AddTrackerViewModel) { + val selectedSuggestionType by viewModel.suggestionType.observeAsState( + TrackerSuggestionType.LABEL_ONLY + ) + + val suggestionTypeMap = mapOf( + TrackerSuggestionType.VALUE_AND_LABEL to stringResource(R.string.value_and_label), + TrackerSuggestionType.VALUE_ONLY to stringResource(R.string.value_only), + TrackerSuggestionType.LABEL_ONLY to stringResource(R.string.label_only), + TrackerSuggestionType.NONE to stringResource(R.string.none) + ) + + LabeledRow(label = stringResource(id = R.string.type_colon)) { + TextMapSpinner( + strings = suggestionTypeMap, + selectedItem = selectedSuggestionType, + onItemSelected = { viewModel.onSuggestionTypeChanged(it) } + ) + } +} + +@Composable +fun SuggestionOrder(viewModel: AddTrackerViewModel) { + + val selectedSuggestionType by viewModel.suggestionType + .observeAsState(TrackerSuggestionType.LABEL_ONLY) + + if (selectedSuggestionType != TrackerSuggestionType.NONE) { + val selectedSuggestionOrder by viewModel.suggestionOrder.observeAsState( + TrackerSuggestionOrder.LABEL_ASCENDING + ) + + val suggestionOrderMap = mapOf( + TrackerSuggestionOrder.VALUE_ASCENDING to stringResource(R.string.value_ascending), + TrackerSuggestionOrder.VALUE_DESCENDING to stringResource(R.string.value_descending), + TrackerSuggestionOrder.LABEL_ASCENDING to stringResource(R.string.label_ascending), + TrackerSuggestionOrder.LABEL_DESCENDING to stringResource(R.string.label_descending), + TrackerSuggestionOrder.LATEST to stringResource(R.string.latest), + TrackerSuggestionOrder.OLDEST to stringResource(R.string.oldest) + ) + + LabeledRow(label = stringResource(id = R.string.order_colon)) { + TextMapSpinner( + strings = suggestionOrderMap, + selectedItem = selectedSuggestionOrder, + onItemSelected = { viewModel.onSuggestionOrderChanged(it) } + ) + } + } +} + +@Composable +private fun AdvancedSectionHeader( + expanded: Boolean, + onClick: () -> Unit = {} +) = Row( + modifier = Modifier.clickable( + onClick = onClick, + interactionSource = remember { MutableInteractionSource() }, + indication = null + ), + verticalAlignment = Alignment.CenterVertically +) { + Divider() + DialogInputSpacing() + Text( + text = stringResource(id = R.string.advanced_options), + style = MaterialTheme.typography.subtitle1 + ) + Icon( + modifier = Modifier.rotate(if (expanded) 180f else 0f), + painter = painterResource(id = R.drawable.down_arrow), + contentDescription = null + ) + DialogInputSpacing() + Divider() +} + +@Composable +private fun RowScope.Divider() { + Box( + modifier = Modifier + .weight(1f) + .height(1.dp) + .background(MaterialTheme.colors.onSurface.copy(alpha = 0.2f)) + ) +} + +@Composable +private fun DefaultValueOptions(viewModel: AddTrackerViewModel) { + val hasDefaultValue = viewModel.hasDefaultValue.observeAsState(false) + val isDuration = viewModel.isDuration.observeAsState(false) + + val focusManager = LocalFocusManager.current + DefaultValueCheckbox(hasDefaultValue.value, viewModel) + + if (hasDefaultValue.value) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (isDuration.value) DurationInputRow(viewModel) + else ValueInputRow(viewModel, focusManager) + + DialogInputSpacing() + + LabelInputRow(viewModel) + } + } +} + +@Composable +private fun DurationConversionModeInput( + isDuration: Boolean, + durationConversionMode: TrackerHelper.DurationNumericConversionMode?, + viewModel: AddTrackerViewModel +) { + val strings = mapOf( + TrackerHelper.DurationNumericConversionMode.HOURS to stringResource(id = R.string.hours), + TrackerHelper.DurationNumericConversionMode.MINUTES to stringResource(id = R.string.minutes), + TrackerHelper.DurationNumericConversionMode.SECONDS to stringResource(id = R.string.seconds) + ) + + Column(modifier = Modifier.fillMaxWidth()) { + val name = + if (isDuration) stringResource(id = R.string.numeric_to_duration_mode_header) + else stringResource(id = R.string.duration_to_numeric_mode_header) + Text( + text = name, + modifier = Modifier + .padding(horizontal = dimensionResource(id = R.dimen.card_padding)), + style = MaterialTheme.typography.subtitle2 + ) + TextMapSpinner( + strings = strings, + selectedItem = durationConversionMode + ?: TrackerHelper.DurationNumericConversionMode.HOURS, + onItemSelected = viewModel::onDurationNumericConversionModeChanged + ) + } +} + +@Composable +private fun LabelInputRow(viewModel: AddTrackerViewModel) { + LabelInputTextField( + textFieldValue = viewModel.defaultLabel, + onValueChange = viewModel::onDefaultLabelChanged + ) +} + +@Composable +private fun ValueInputRow( + viewModel: AddTrackerViewModel, + focusManager: FocusManager +) { + ValueInputTextField( + textFieldValue = viewModel.defaultValue, + onValueChange = viewModel::onDefaultValueChanged, + focusManager = focusManager + ) +} + +@Composable +private fun DurationInputRow(viewModel: AddTrackerViewModel) { + DurationInput(viewModel = viewModel) +} + +@Composable +private fun DefaultValueCheckbox( + hasDefaultValue: Boolean, + viewModel: AddTrackerViewModel +) { + RowCheckbox( + checked = hasDefaultValue, + onCheckedChange = { viewModel.onHasDefaultValueChanged(it) }, + text = stringResource(id = R.string.use_default_value) + ) +} + +@Composable +private fun DurationCheckbox( + isDuration: Boolean, + viewModel: AddTrackerViewModel +) { + RowCheckbox( + checked = isDuration, + onCheckedChange = { viewModel.onIsDurationCheckChanged(it) }, + text = stringResource(id = R.string.tracker_type) + ) +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun DescriptionInput( + viewModel: AddTrackerViewModel +) = FullWidthTextField( + textFieldValue = viewModel.trackerDescription, + onValueChange = viewModel::onTrackerDescriptionChanged, + label = stringResource(id = R.string.add_a_longer_description_optional), + singleLine = false +) + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun NameInput( + viewModel: AddTrackerViewModel, + focusManager: FocusManager, + focusRequester: FocusRequester, + keyboardController: SoftwareKeyboardController? +) = FullWidthTextField( + textFieldValue = viewModel.trackerName, + onValueChange = viewModel::onTrackerNameChanged, + label = stringResource(id = R.string.tracker_name), + focusManager = focusManager, + focusRequester = focusRequester, + keyboardController = keyboardController +) + diff --git a/app/src/main/java/com/samco/trackandgraph/addtracker/AddTrackerViewModel.kt b/app/src/main/java/com/samco/trackandgraph/addtracker/AddTrackerViewModel.kt new file mode 100644 index 000000000..448fcc5d7 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/addtracker/AddTrackerViewModel.kt @@ -0,0 +1,270 @@ +package com.samco.trackandgraph.addtracker + +import androidx.compose.runtime.* +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.* +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.DataType +import com.samco.trackandgraph.base.database.dto.Tracker +import com.samco.trackandgraph.base.database.dto.TrackerSuggestionOrder +import com.samco.trackandgraph.base.database.dto.TrackerSuggestionType +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.TrackerHelper +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.base.model.di.MainDispatcher +import com.samco.trackandgraph.ui.viewmodels.DurationInputViewModel +import com.samco.trackandgraph.ui.viewmodels.DurationInputViewModelImpl +import com.samco.trackandgraph.ui.viewmodels.asValidatedDouble +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +interface AddTrackerViewModel : DurationInputViewModel { + //Outputs + val trackerName: TextFieldValue + val trackerDescription: TextFieldValue + val isDuration: LiveData + val isLoading: LiveData + val hasDefaultValue: LiveData + val defaultValue: TextFieldValue + val defaultLabel: TextFieldValue + val createButtonEnabled: LiveData + val errorText: LiveData + val durationNumericConversionMode: LiveData + val shouldShowDurationConversionModeSpinner: LiveData + val isUpdateMode: LiveData + val showUpdateWarningAlertDialog: LiveData + val suggestionType: LiveData + val suggestionOrder: LiveData + + //Inputs + fun onTrackerNameChanged(name: TextFieldValue) + fun onTrackerDescriptionChanged(description: TextFieldValue) + fun onIsDurationCheckChanged(isDuration: Boolean) + fun onHasDefaultValueChanged(hasDefaultValue: Boolean) + fun onDefaultValueChanged(defaultValue: TextFieldValue) + fun onDefaultLabelChanged(defaultLabel: TextFieldValue) + fun onDurationNumericConversionModeChanged(durationNumericConversionMode: TrackerHelper.DurationNumericConversionMode) + fun onConfirmUpdate() + fun onDismissUpdateWarningCancel() + fun onCreateUpdateClicked() + fun onSuggestionTypeChanged(suggestionType: TrackerSuggestionType) + fun onSuggestionOrderChanged(suggestionOrder: TrackerSuggestionOrder) +} + +//TODO so much mutable state :/ ugly +@HiltViewModel +class AddTrackerViewModelImpl @Inject constructor( + private val dataInteractor: DataInteractor, + @IODispatcher private val io: CoroutineDispatcher, + @MainDispatcher private val ui: CoroutineDispatcher, +) : ViewModel(), AddTrackerViewModel, DurationInputViewModel by DurationInputViewModelImpl() { + + private var disallowedNames: List? = null + + private sealed interface ValidationError { + object NoName : ValidationError + object NameAlreadyExists : ValidationError + } + + private val isUpdateModeFlow = MutableStateFlow(false) + private val isDurationModeFlow = MutableStateFlow(false) + + override var trackerName by mutableStateOf(TextFieldValue("")) + override var trackerDescription by mutableStateOf(TextFieldValue("")) + override val isLoading = MutableLiveData(false) + override val hasDefaultValue = MutableLiveData(false) + override var defaultValue by mutableStateOf(TextFieldValue("1.0", TextRange(0, 3))) + override var defaultLabel by mutableStateOf(TextFieldValue()) + override val isDuration = isDurationModeFlow + .asLiveData(viewModelScope.coroutineContext) + override val isUpdateMode: LiveData = + isUpdateModeFlow.asLiveData(viewModelScope.coroutineContext) + override val showUpdateWarningAlertDialog = MutableLiveData(false) + + override val suggestionType = MutableLiveData(TrackerSuggestionType.LABEL_ONLY) + override val suggestionOrder = MutableLiveData(TrackerSuggestionOrder.LABEL_ASCENDING) + + override fun onTrackerNameChanged(name: TextFieldValue) { + trackerName = name + } + + override fun onTrackerDescriptionChanged(description: TextFieldValue) { + trackerDescription = description + } + + private val validationErrorFlow = snapshotFlow { trackerName } + .map { + when { + it.text.isBlank() -> ValidationError.NoName + disallowedNames?.contains(it.text) == true -> ValidationError.NameAlreadyExists + else -> null + } + } + + override val createButtonEnabled = validationErrorFlow + .map { it == null } + .asLiveData(viewModelScope.coroutineContext) + override val errorText = validationErrorFlow + .map { + when (it) { + ValidationError.NoName -> R.string.tracker_name_cannot_be_null + ValidationError.NameAlreadyExists -> R.string.tracker_with_that_name_exists + else -> null + } + } + .asLiveData(viewModelScope.coroutineContext) + override val durationNumericConversionMode = + MutableLiveData(TrackerHelper.DurationNumericConversionMode.HOURS) + override val shouldShowDurationConversionModeSpinner: LiveData = + combine(isUpdateModeFlow, isDurationModeFlow) { a, b -> + //If we're in update mode and we've changed to or from duration we need to show + // a conversion mode spinner + a && (existingTracker?.dataType == DataType.DURATION) != b + }.asLiveData(viewModelScope.coroutineContext) + val complete = MutableLiveData(false) + + private var groupId: Long = -1 + private var existingTracker: Tracker? = null + private var initialized = false + + fun init(groupId: Long, existingTrackerId: Long) { + if (initialized) return + initialized = true + + this.groupId = groupId + viewModelScope.launch(io) { + withContext(ui) { isLoading.value = true } + dataInteractor.getTrackerById(existingTrackerId)?.let { + initFromTracker(it) + } + disallowedNames = dataInteractor + .getFeaturesForGroupSync(groupId) + .map { it.name } + .filter { it != existingTracker?.name } + withContext(ui) { isLoading.value = false } + } + } + + private suspend fun initFromTracker(tracker: Tracker) = withContext(ui) { + existingTracker = tracker + trackerName = TextFieldValue(tracker.name, TextRange(tracker.name.length)) + trackerDescription = TextFieldValue(tracker.description, TextRange(tracker.name.length)) + + isDurationModeFlow.value = tracker.dataType == DataType.DURATION + hasDefaultValue.value = tracker.hasDefaultValue + val defaultValueStr = tracker.defaultValue.toString() + if (isDurationModeFlow.value) setDurationFromDouble(tracker.defaultValue) + else defaultValue = TextFieldValue(defaultValueStr, TextRange(defaultValueStr.length)) + + defaultLabel = TextFieldValue(tracker.defaultLabel, TextRange(tracker.defaultLabel.length)) + suggestionType.value = tracker.suggestionType + suggestionOrder.value = tracker.suggestionOrder + isUpdateModeFlow.value = true + } + + override fun onIsDurationCheckChanged(isDuration: Boolean) { + this.isDurationModeFlow.value = isDuration + } + + override fun onHasDefaultValueChanged(hasDefaultValue: Boolean) { + this.hasDefaultValue.value = hasDefaultValue + } + + override fun onDefaultValueChanged(defaultValue: TextFieldValue) { + this.defaultValue = defaultValue.copy(text = defaultValue.text.asValidatedDouble()) + } + + override fun onDefaultLabelChanged(defaultLabel: TextFieldValue) { + this.defaultLabel = defaultLabel + } + + override fun onDurationNumericConversionModeChanged(durationNumericConversionMode: TrackerHelper.DurationNumericConversionMode) { + this.durationNumericConversionMode.value = durationNumericConversionMode + } + + override fun onConfirmUpdate() { + showUpdateWarningAlertDialog.value = false + viewModelScope.launch(io) { + withContext(ui) { isLoading.value = true } + //should not be null here but just in case lets fallback to add + existingTracker?.let { updateTracker(it) } ?: addTracker() + withContext(ui) { isLoading.value = false } + withContext(ui) { complete.value = true } + } + } + + override fun onDismissUpdateWarningCancel() { + showUpdateWarningAlertDialog.value = false + } + + override fun onCreateUpdateClicked() { + if (isUpdateModeFlow.value) { + showUpdateWarningAlertDialog.value = true + } else { + viewModelScope.launch(io) { + withContext(ui) { isLoading.value = true } + addTracker() + withContext(ui) { isLoading.value = false } + withContext(ui) { complete.value = true } + } + } + } + + override fun onSuggestionTypeChanged(suggestionType: TrackerSuggestionType) { + this.suggestionType.value = suggestionType + } + + override fun onSuggestionOrderChanged(suggestionOrder: TrackerSuggestionOrder) { + this.suggestionOrder.value = suggestionOrder + } + + private fun getDataType() = when (isDuration.value) { + true -> DataType.DURATION + else -> DataType.CONTINUOUS + } + + private fun getDefaultValue() = when (isDuration.value) { + true -> getDurationAsDouble() + else -> defaultValue.text.toDoubleOrNull() + } + + private suspend fun updateTracker(existingTracker: Tracker) { + dataInteractor.updateTracker( + oldTracker = existingTracker, + durationNumericConversionMode = durationNumericConversionMode.value, + newName = trackerName.text, + newType = getDataType(), + hasDefaultValue = hasDefaultValue.value, + defaultValue = getDefaultValue() ?: 1.0, + featureDescription = trackerDescription.text, + defaultLabel = defaultLabel.text, + suggestionType = suggestionType.value, + suggestionOrder = suggestionOrder.value + ) + } + + private suspend fun addTracker() { + val tracker = Tracker( + id = 0L, + name = trackerName.text, + groupId = groupId, + featureId = 0L, + displayIndex = 0, + description = trackerDescription.text, + dataType = getDataType(), + hasDefaultValue = hasDefaultValue.value ?: false, + defaultValue = getDefaultValue() ?: 1.0, + defaultLabel = defaultLabel.text, + suggestionType = suggestionType.value ?: TrackerSuggestionType.VALUE_AND_LABEL, + suggestionOrder = suggestionOrder.value ?: TrackerSuggestionOrder.VALUE_ASCENDING + ) + dataInteractor.insertTracker(tracker) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/ExportFeaturesDialog.kt b/app/src/main/java/com/samco/trackandgraph/addtracker/ExportFeaturesDialog.kt similarity index 54% rename from app/src/main/java/com/samco/trackandgraph/displaytrackgroup/ExportFeaturesDialog.kt rename to app/src/main/java/com/samco/trackandgraph/addtracker/ExportFeaturesDialog.kt index bca7d9ec7..2e5ecffb8 100644 --- a/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/ExportFeaturesDialog.kt +++ b/app/src/main/java/com/samco/trackandgraph/addtracker/ExportFeaturesDialog.kt @@ -1,7 +1,23 @@ -package com.samco.trackandgraph.displaytrackgroup +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.addtracker -import android.app.Activity import android.app.Dialog +import android.content.ContentResolver import android.content.DialogInterface import android.content.Intent import android.net.Uri @@ -9,30 +25,39 @@ import android.os.Bundle import android.view.View import android.widget.* import androidx.appcompat.app.AlertDialog -import androidx.core.content.ContextCompat import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProviders +import androidx.lifecycle.viewModelScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.samco.trackandgraph.R -import com.samco.trackandgraph.database.Feature -import com.samco.trackandgraph.database.TrackAndGraphDatabase -import com.samco.trackandgraph.util.CSVReadWriter +import com.samco.trackandgraph.base.database.dto.Feature +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.base.model.di.MainDispatcher import com.samco.trackandgraph.util.ImportExportFeatureUtils +import com.samco.trackandgraph.util.getColorFromAttr +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.* import org.threeten.bp.OffsetDateTime +import javax.inject.Inject +const val GROUP_ID_KEY = "GROUP_ID_KEY" +const val GROUP_NAME_KEY = "GROUP_NAME_KEY" const val CREATE_FILE_REQUEST_CODE = 123 enum class ExportState { LOADING, WAITING, EXPORTING, DONE } + +@AndroidEntryPoint class ExportFeaturesDialog : DialogFragment() { - private var trackGroupName: String? = null - private var trackGroupId: Long? = null + private var groupName: String? = null + private var groupId: Long? = null - private lateinit var viewModel: ExportFeaturesViewModel + private val viewModel by viewModels() private lateinit var alertDialog: AlertDialog private lateinit var fileButton: Button @@ -42,10 +67,9 @@ class ExportFeaturesDialog : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { return activity?.let { - viewModel = ViewModelProviders.of(this).get(ExportFeaturesViewModel::class.java) val view = it.layoutInflater.inflate(R.layout.export_features_dialog, null) - trackGroupName = arguments!!.getString(TRACK_GROUP_NAME_KEY) - trackGroupId = arguments!!.getLong(TRACK_GROUP_ID_KEY) + groupName = requireArguments().getString(GROUP_NAME_KEY) + groupId = requireArguments().getLong(GROUP_ID_KEY) fileButton = view.findViewById(R.id.fileButton) progressBar = view.findViewById(R.id.progressBar) @@ -53,13 +77,13 @@ class ExportFeaturesDialog : DialogFragment() { fileButton.setOnClickListener { onFileButtonClicked() } fileButton.text = getString(R.string.select_file) - fileButton.setTextColor(ContextCompat.getColor(context!!, R.color.errorText)) + fileButton.setTextColor(fileButton.context.getColorFromAttr(R.attr.colorError)) - val builder = AlertDialog.Builder(it) - builder.setView(view) - .setPositiveButton(R.string.exportButton) { _, _ -> null } - .setNegativeButton(R.string.cancel) { _, _ -> null } - alertDialog = builder.create() + alertDialog = MaterialAlertDialogBuilder(it, R.style.AppTheme_AlertDialogTheme) + .setView(view) + .setPositiveButton(R.string.exportButton) { _, _ -> run {} } + .setNegativeButton(R.string.cancel) { _, _ -> run {} } + .create() alertDialog.setCanceledOnTouchOutside(true) alertDialog.setOnShowListener { setAlertDialogShowListeners() } alertDialog @@ -69,21 +93,23 @@ class ExportFeaturesDialog : DialogFragment() { private fun setAlertDialogShowListeners() { positiveButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE) positiveButton.isEnabled = false - viewModel.loadFeatures(activity!!, trackGroupId!!) + viewModel.loadFeatures(groupId!!) listenToState() setUriListeners() listenToFeatures() - positiveButton.setOnClickListener { viewModel.beginExport(activity!!) } + positiveButton.setOnClickListener { viewModel.beginExport() } } private fun listenToState() { - viewModel.exportState.observe(this, Observer { state -> + viewModel.exportState.observe(this) { state -> when (state) { ExportState.LOADING -> { progressBar.visibility = View.VISIBLE positiveButton.isEnabled = false } - ExportState.WAITING -> { progressBar.visibility = View.INVISIBLE } + ExportState.WAITING -> { + progressBar.visibility = View.INVISIBLE + } ExportState.EXPORTING -> { progressBar.visibility = View.VISIBLE positiveButton.isEnabled = false @@ -91,31 +117,41 @@ class ExportFeaturesDialog : DialogFragment() { ExportState.DONE -> { dismiss() } + else -> {} } - }) + } } private fun setUriListeners() { - viewModel.selectedFileUri.observe(this, Observer { uri -> + viewModel.selectedFileUri.observe(this) { uri -> if (uri != null) { - ImportExportFeatureUtils.setFileButtonTextFromUri(activity, context!!, uri, fileButton, alertDialog) + ImportExportFeatureUtils.setFileButtonTextFromUri( + activity, + uri, + fileButton, + alertDialog + ) } - }) + } } private fun listenToFeatures() { - viewModel.featuresLoaded.observe(this, Observer { loaded -> - if (loaded) { createFeatureCheckboxes() } - }) + viewModel.featuresLoaded.observe(this) { loaded -> + if (loaded) { + createFeatureCheckboxes() + } + } } private fun onFileButtonClicked() { val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) val now = OffsetDateTime.now() - val generatedName = getString( R.string.export_file_name_suffix, - getString(R.string.app_name), trackGroupName, now.year, now.monthValue + 1, - now.dayOfMonth, now.hour, now.minute, now.second) + val generatedName = getString( + R.string.export_file_name_suffix, + "TrackAndGraph", groupName, now.year, now.monthValue, + now.dayOfMonth, now.hour, now.minute, now.second + ) putExtra(Intent.EXTRA_TITLE, generatedName) type = "text/csv" } @@ -135,13 +171,21 @@ class ExportFeaturesDialog : DialogFragment() { private fun createFeatureCheckboxes() { for (feature in viewModel.features) { - val item = layoutInflater.inflate(R.layout.list_item_feature_checkbox, checkboxLayout, false) + val item = layoutInflater.inflate( + R.layout.list_item_feature_checkbox, + checkboxLayout, + false + ) val checkBox = item.findViewById(R.id.checkbox) checkBox.text = feature.name checkBox.isChecked = viewModel.selectedFeatures.contains(feature) checkBox.setOnCheckedChangeListener { _, b -> - if (b && !viewModel.selectedFeatures.contains(feature)) viewModel.selectedFeatures.add(feature) - else if (!b && viewModel.selectedFeatures.contains(feature)) viewModel.selectedFeatures.remove(feature) + if (b && !viewModel.selectedFeatures.contains(feature)) viewModel.selectedFeatures.add( + feature + ) + else if (!b && viewModel.selectedFeatures.contains(feature)) viewModel.selectedFeatures.remove( + feature + ) } checkboxLayout.addView(item) } @@ -154,13 +198,19 @@ class ExportFeaturesDialog : DialogFragment() { } -class ExportFeaturesViewModel : ViewModel() { - private var updateJob = Job() - private val uiScope = CoroutineScope(Dispatchers.Main + updateJob) - +@HiltViewModel +class ExportFeaturesViewModel @Inject constructor( + private val dataInteractor: DataInteractor, + @MainDispatcher private val ui: CoroutineDispatcher, + @IODispatcher private val io: CoroutineDispatcher, + private val contentResolver: ContentResolver +) : ViewModel() { lateinit var features: List - val exportState: LiveData get() { return _exportState } + val exportState: LiveData + get() { + return _exportState + } private val _exportState by lazy { val state = MutableLiveData() state.value = ExportState.WAITING @@ -174,21 +224,22 @@ class ExportFeaturesViewModel : ViewModel() { return@lazy uri } - val featuresLoaded: LiveData get() { return _featuresLoaded } + val featuresLoaded: LiveData + get() { + return _featuresLoaded + } private val _featuresLoaded by lazy { val loaded = MutableLiveData() loaded.value = false return@lazy loaded } - fun loadFeatures(activity: Activity, trackGroupId: Long) { + fun loadFeatures(groupId: Long) { if (_featuresLoaded.value == false) { - uiScope.launch { + viewModelScope.launch(ui) { _exportState.value = ExportState.LOADING - val application = activity.application - val dao = TrackAndGraphDatabase.getInstance(application).trackAndGraphDatabaseDao - withContext(Dispatchers.IO) { - features = dao.getFeaturesForTrackGroupSync(trackGroupId).toMutableList() + withContext(io) { + features = dataInteractor.getFeaturesForGroupSync(groupId).toMutableList() } selectedFeatures = features.toMutableList() _featuresLoaded.value = true @@ -197,40 +248,23 @@ class ExportFeaturesViewModel : ViewModel() { } } - fun beginExport(activity: Activity) { - selectedFileUri.value?.let { - uiScope.launch { + //TODO this should probably be scheduled to run in a service + fun beginExport() { + selectedFileUri.value?.let { uri -> + viewModelScope.launch(ui) { _exportState.value = ExportState.EXPORTING - withContext(Dispatchers.IO) { - val outStream = activity.contentResolver.openOutputStream(it) - if (outStream != null) { - val application = activity.application - val dao = TrackAndGraphDatabase.getInstance(application).trackAndGraphDatabaseDao - CSVReadWriter.writeFeaturesToCSV(selectedFeatures, dao, outStream) - } - } + doExport(uri) _exportState.value = ExportState.DONE } } } - override fun onCleared() { - super.onCleared() - updateJob.cancel() + private suspend fun doExport(uri: Uri) = runCatching { + withContext(io) { + contentResolver.openOutputStream(uri)?.let { outStream -> + val featureIds = selectedFeatures.map { it.featureId } + dataInteractor.writeFeaturesToCSV(outStream, featureIds) + } + } } } - - - - - - - - - - - - - - - diff --git a/app/src/main/java/com/samco/trackandgraph/addtracker/ImportFeaturesDialog.kt b/app/src/main/java/com/samco/trackandgraph/addtracker/ImportFeaturesDialog.kt new file mode 100644 index 000000000..5ecdbe028 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/addtracker/ImportFeaturesDialog.kt @@ -0,0 +1,255 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.addtracker + +import android.app.Dialog +import android.content.ContentResolver +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.widget.Button +import android.widget.ProgressBar +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.* +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.ImportFeaturesException +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.base.model.di.MainDispatcher +import com.samco.trackandgraph.util.ImportExportFeatureUtils +import com.samco.trackandgraph.util.getColorFromAttr +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.* +import javax.inject.Inject + +const val OPEN_FILE_REQUEST_CODE = 124 + +enum class ImportState { WAITING, IMPORTING, DONE } + +@AndroidEntryPoint +class ImportFeaturesDialog : DialogFragment() { + private var trackGroupName: String? = null + private var trackGroupId: Long? = null + + private val viewModel by viewModels() + private lateinit var alertDialog: AlertDialog + private lateinit var fileButton: Button + private lateinit var progressBar: ProgressBar + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return activity?.let { + val view = it.layoutInflater.inflate(R.layout.import_features_dialog, null) + trackGroupName = requireArguments().getString(GROUP_NAME_KEY) + trackGroupId = requireArguments().getLong(GROUP_ID_KEY) + + fileButton = view.findViewById(R.id.fileButton) + progressBar = view.findViewById(R.id.progressBar) + + progressBar.visibility = View.INVISIBLE + fileButton.setOnClickListener { onFileButtonClicked() } + fileButton.text = getString(R.string.select_file) + fileButton.setTextColor(fileButton.context.getColorFromAttr(R.attr.colorError)) + + val builder = MaterialAlertDialogBuilder(it, R.style.AppTheme_AlertDialogTheme) + builder.setView(view) + .setPositiveButton(R.string.importButton) { _, _ -> run {} } + .setNegativeButton(R.string.cancel) { _, _ -> run {} } + alertDialog = builder.create() + alertDialog.setCanceledOnTouchOutside(true) + alertDialog.setOnShowListener { setAlertDialogShowListeners() } + alertDialog + } ?: throw IllegalStateException("Activity cannot be null") + } + + private fun setAlertDialogShowListeners() { + val positiveButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE) + positiveButton.isEnabled = false + positiveButton.setOnClickListener { onImportClicked() } + listenToUri() + listenToImportState() + listenToException() + val negativeButton = alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE) + negativeButton.setOnClickListener { dismiss() } + alertDialog.setOnCancelListener { run {} } + } + + private fun listenToUri() { + viewModel.selectedFileUri.observe(this, Observer { uri -> + if (uri != null) { + ImportExportFeatureUtils.setFileButtonTextFromUri( + activity, + uri, + fileButton, + alertDialog + ) + alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true + } + }) + } + + private fun listenToImportState() { + viewModel.importState.observe(this, Observer { state -> + when (state) { + ImportState.WAITING -> { + progressBar.visibility = View.INVISIBLE + } + ImportState.IMPORTING -> { + progressBar.visibility = View.VISIBLE + val positiveButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE) + positiveButton.isEnabled = false + fileButton.isEnabled = false + } + ImportState.DONE -> dismiss() + else -> {} + } + }) + } + + private fun listenToException() { + viewModel.importException.observe(this, Observer { exception -> + exception?.let { + Toast.makeText( + requireActivity(), + getStringForImportException(it), + Toast.LENGTH_LONG + ).show() + } + }) + } + + private fun getStringForImportException(exception: ImportFeaturesException) = when (exception) { + is ImportFeaturesException.Unknown -> + getString(R.string.import_exception_unknown) + is ImportFeaturesException.InconsistentDataType -> getString( + R.string.import_exception_inconsistent_data_type, + exception.lineNumber + ) + is ImportFeaturesException.InconsistentRecord -> getString( + R.string.import_exception_inconsistent_record, + exception.lineNumber + ) + is ImportFeaturesException.BadTimestamp -> getString( + R.string.import_exception_bad_timestamp, + exception.lineNumber + ) + is ImportFeaturesException.BadHeaders -> getString( + R.string.import_exception_bad_headers, + exception.requiredHeaders + ) + } + + private fun onFileButtonClicked() { + val intent = Intent(Intent.ACTION_GET_CONTENT).apply { + type = "text/csv" + putExtra( + Intent.EXTRA_MIME_TYPES, arrayOf( + "text/csv", + "application/csv", + "application/comma-separated-values", + "text/comma-separated-values" + ) + ) + } + startActivityForResult(intent, OPEN_FILE_REQUEST_CODE) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) { + super.onActivityResult(requestCode, resultCode, resultData) + if (requestCode == OPEN_FILE_REQUEST_CODE) { + resultData?.data.also { uri -> + if (uri != null) { + viewModel.selectedFileUri.value = uri + } + } + } + } + + private fun onImportClicked() { + progressBar.visibility = View.VISIBLE + viewModel.beginImport(trackGroupId!!) + } + + override fun onCancel(dialog: DialogInterface) { + super.onCancel(dialog) + if (viewModel.importState.value != ImportState.IMPORTING) dismiss() + } +} + +@HiltViewModel +class ImportFeaturesViewModel @Inject constructor( + private val dataInteractor: DataInteractor, + private val contentResolver: ContentResolver, + @MainDispatcher private val ui: CoroutineDispatcher, + @IODispatcher private val io: CoroutineDispatcher +) : ViewModel() { + + val selectedFileUri: MutableLiveData by lazy { + val uri = MutableLiveData() + uri.value = null + return@lazy uri + } + + val importState: LiveData + get() { + return _importState + } + private val _importState: MutableLiveData by lazy { + val state = MutableLiveData() + state.value = ImportState.WAITING + return@lazy state + } + + private val _importException: MutableLiveData by lazy { + val exception = MutableLiveData() + exception.value = null + return@lazy exception + } + + val importException: LiveData + get() { + return _importException + } + + //TODO this should probably be scheduled to run in a service + fun beginImport(trackGroupId: Long) { + if (_importState.value == ImportState.IMPORTING) return + val uri = selectedFileUri.value ?: return + viewModelScope.launch(ui) { + _importState.value = ImportState.IMPORTING + doImport(uri, trackGroupId).exceptionOrNull()?.let { + if (it is ImportFeaturesException) _importException.value = it + else _importException.value = ImportFeaturesException.Unknown() + } + _importState.value = ImportState.DONE + } + } + + private suspend fun doImport(uri: Uri, trackGroupId: Long) = runCatching { + withContext(io) { + contentResolver.openInputStream(uri)?.let { inputStream -> + dataInteractor.readFeaturesFromCSV(inputStream, trackGroupId) + } + } + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/backupandrestore/AutoBackupViewModel.kt b/app/src/main/java/com/samco/trackandgraph/backupandrestore/AutoBackupViewModel.kt new file mode 100644 index 000000000..eacaa2e17 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/backupandrestore/AutoBackupViewModel.kt @@ -0,0 +1,212 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.backupandrestore + +import android.net.Uri +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.samco.trackandgraph.R +import com.samco.trackandgraph.backupandrestore.dto.BackupConfig +import com.samco.trackandgraph.backupandrestore.dto.BackupResult +import com.samco.trackandgraph.ui.compose.ui.SelectedTime +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.threeten.bp.OffsetDateTime +import org.threeten.bp.temporal.ChronoUnit +import javax.inject.Inject + +interface AutoBackupViewModel { + val autoBackupLocation: StateFlow + val autoBackupFirstDate: StateFlow + val autoBackupIntervalTextFieldValue: State + val autoBackupUnit: StateFlow + val autoBackupConfigValid: StateFlow + val autoBackupConfigFailures: Flow + + fun onConfirmAutoBackup() + fun onBackupLocationChanged(uri: Uri?) + fun onBackupIntervalChanged(text: TextFieldValue) + fun onBackupUnitChanged(unit: ChronoUnit) + fun onAutoBackupFirstDateChanged(offsetDateTime: OffsetDateTime) + fun onAutoBackupFirstDateChanged(selectedTime: SelectedTime) + fun onCancelConfig() +} + +@HiltViewModel +class AutoBackupViewModelImpl @Inject constructor( + private val interactor: BackupRestoreInteractor +) : ViewModel(), AutoBackupViewModel { + + private val onUserSetUri = MutableSharedFlow(extraBufferCapacity = 1) + + private val onCancelEdit = MutableSharedFlow() + + private val storedBackupConfig = merge( + interactor.autoBackupConfig.distinctUntilChanged(), + onCancelEdit.map { interactor.getAutoBackupInfo() } + ).shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 1) + + private val uri: StateFlow = merge( + onUserSetUri, + storedBackupConfig.map { it?.uri } + ).stateIn(viewModelScope, SharingStarted.Eagerly, null) + + override val autoBackupLocation: StateFlow = uri + .map { it?.toString() } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + private val onUserSetAutoBackupDate = MutableSharedFlow(extraBufferCapacity = 1) + + override val autoBackupFirstDate = merge( + onUserSetAutoBackupDate, + storedBackupConfig.mapNotNull { it?.nextScheduled } + ).stateIn(viewModelScope, SharingStarted.Eagerly, OffsetDateTime.now().plusHours(1)) + + override var autoBackupIntervalTextFieldValue: MutableState = + mutableStateOf(TextFieldValue("1", TextRange(0, 1))) + + private val onUserSetAutoBackupUnit = MutableSharedFlow(extraBufferCapacity = 1) + + override val autoBackupUnit = merge( + onUserSetAutoBackupUnit, + storedBackupConfig.mapNotNull { it?.units } + ).stateIn(viewModelScope, SharingStarted.Eagerly, ChronoUnit.DAYS) + + init { + //Shame to use a side effect for this but we can't separate TextFieldValue into a flow + // it causes havoc if it is not modified synchronously in the UI + viewModelScope.launch { + storedBackupConfig.filterNotNull().collect { + autoBackupIntervalTextFieldValue.value = TextFieldValue( + it.interval.toString(), + TextRange(0, it.interval.toString().length) + ) + } + } + } + + private val currentBackupConfig: StateFlow = combine( + uri, + autoBackupFirstDate, + snapshotFlow { autoBackupIntervalTextFieldValue.value.text }, + autoBackupUnit + ) { uri, firstDate, interval, unit -> + val intervalInt = interval.toIntOrNull() ?: return@combine null + val uriNonNull = uri ?: return@combine null + val config = BackupConfig(uriNonNull, firstDate, intervalInt, unit) + if (interactor.validAutoBackupConfiguration(config)) config else null + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + BackupConfig(Uri.EMPTY, OffsetDateTime.now(), 1, ChronoUnit.DAYS) + ) + + override val autoBackupConfigValid: StateFlow = currentBackupConfig + .map { it != null } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + private val onBackupConfigApply = MutableSharedFlow() + + override val autoBackupConfigFailures: Flow = onBackupConfigApply + .mapNotNull { currentBackupConfig.value } + .map { interactor.backupNowAndSetAutoBackupConfig(it) } + .filter { it != BackupResult.SUCCESS } + .map { + when (it) { + BackupResult.FAIL_COULD_NOT_WRITE_TO_FILE -> R.string.backup_error_could_not_write_to_file + BackupResult.FAIL_COULD_NOT_FIND_DATABASE -> R.string.backup_error_could_not_find_database_file + BackupResult.FAIL_COULD_NOT_COPY -> R.string.backup_error_failed_to_copy_database + BackupResult.SUCCESS -> throw IllegalStateException("Invalid backup result") + } + } + .shareIn(viewModelScope, SharingStarted.Eagerly, 1) + + override fun onConfirmAutoBackup() { + viewModelScope.launch { onBackupConfigApply.emit(Unit) } + } + + override fun onBackupLocationChanged(uri: Uri?) { + if (uri == null) return + viewModelScope.launch { onUserSetUri.emit(uri) } + } + + private fun moveUntilValid(odt: OffsetDateTime, unit: ChronoUnit): OffsetDateTime { + var new = odt + while (new.isBefore(OffsetDateTime.now())) { + new = new.plus(1, unit) + } + return new + } + + override fun onBackupIntervalChanged(text: TextFieldValue) { + val filtered = text.copy(text = text.text.filter { it.isDigit() }) + if (autoBackupIntervalTextFieldValue.value != filtered) + autoBackupIntervalTextFieldValue.value = filtered + } + + override fun onBackupUnitChanged(unit: ChronoUnit) { + viewModelScope.launch { onUserSetAutoBackupUnit.emit(unit) } + } + + override fun onAutoBackupFirstDateChanged(offsetDateTime: OffsetDateTime) { + val current = autoBackupFirstDate.value + val new = moveUntilValid( + offsetDateTime + .withHour(current.hour) + .withMinute(current.minute) + .withSecond(current.second) + .withNano(current.nano), + ChronoUnit.HOURS + ) + viewModelScope.launch { onUserSetAutoBackupDate.emit(new) } + } + + override fun onAutoBackupFirstDateChanged(selectedTime: SelectedTime) { + val current = autoBackupFirstDate.value + val new = moveUntilValid( + current + .withHour(selectedTime.hour) + .withMinute(selectedTime.minute), + ChronoUnit.DAYS + ) + viewModelScope.launch { onUserSetAutoBackupDate.emit(new) } + } + + override fun onCancelConfig() { + viewModelScope.launch { onCancelEdit.emit(Unit) } + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/backupandrestore/BackupAndRestoreFragment.kt b/app/src/main/java/com/samco/trackandgraph/backupandrestore/BackupAndRestoreFragment.kt new file mode 100644 index 000000000..78883ba94 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/backupandrestore/BackupAndRestoreFragment.kt @@ -0,0 +1,68 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.backupandrestore + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import com.samco.trackandgraph.MainActivity +import com.samco.trackandgraph.NavButtonStyle +import com.samco.trackandgraph.R +import com.samco.trackandgraph.settings.TngSettings +import com.samco.trackandgraph.ui.compose.compositionlocals.LocalSettings +import com.samco.trackandgraph.ui.compose.theming.TnGComposeTheme +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class BackupAndRestoreFragment : Fragment() { + + private val viewModel by viewModels() + + @Inject + lateinit var tngSettings: TngSettings + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setContent { + CompositionLocalProvider(LocalSettings provides tngSettings) { + TnGComposeTheme { + BackupAndRestoreView(viewModel = viewModel) + } + } + } + } + } + + override fun onResume() { + super.onResume() + (requireActivity() as MainActivity).setActionBarConfig( + NavButtonStyle.MENU, + getString(R.string.backup_and_restore) + ) + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/backupandrestore/BackupAndRestoreView.kt b/app/src/main/java/com/samco/trackandgraph/backupandrestore/BackupAndRestoreView.kt new file mode 100644 index 000000000..577bc4085 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/backupandrestore/BackupAndRestoreView.kt @@ -0,0 +1,609 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +@file:OptIn(ExperimentalLayoutApi::class) + +package com.samco.trackandgraph.backupandrestore + +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.Card +import androidx.compose.material.Checkbox +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.helpers.formatDayMonthYearHourMinuteWeekDayOneLine +import com.samco.trackandgraph.base.helpers.getWeekDayNames +import com.samco.trackandgraph.ui.compose.ui.ConfirmDialog +import com.samco.trackandgraph.ui.compose.ui.CustomConfirmCancelDialog +import com.samco.trackandgraph.ui.compose.ui.DateButton +import com.samco.trackandgraph.ui.compose.ui.MiniNumericTextField +import com.samco.trackandgraph.ui.compose.ui.InputSpacingLarge +import com.samco.trackandgraph.ui.compose.ui.DialogInputSpacing +import com.samco.trackandgraph.ui.compose.ui.TextMapSpinner +import com.samco.trackandgraph.ui.compose.ui.TimeButton +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import org.threeten.bp.OffsetDateTime +import org.threeten.bp.temporal.ChronoUnit +import kotlin.system.exitProcess + +private const val SQLITE_MIME_TYPE = "application/vnd.sqlite3" + +@Composable +fun BackupAndRestoreView(viewModel: BackupAndRestoreViewModel) = Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(dimensionResource(id = R.dimen.input_spacing_large)), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally +) { + + InputSpacingLarge() + + BackupCard(viewModel = viewModel) + + val context = LocalContext.current + + val backupSuccessText = stringResource(id = R.string.backup_successful) + + LaunchedEffect(context) { + viewModel.backupError.filterNotNull().collect { + Toast.makeText(context, it, Toast.LENGTH_LONG).show() + } + } + + LaunchedEffect(context) { + viewModel.backupSuccessful.filter { it }.collect { + Toast.makeText(context, backupSuccessText, Toast.LENGTH_SHORT).show() + } + } + + Spacer(modifier = Modifier.weight(1f)) + + var showPreRestoreDialog by rememberSaveable { mutableStateOf(false) } + + Button( + onClick = { showPreRestoreDialog = true }, + ) { + Text( + text = stringResource(id = R.string.restore_data).uppercase(), + style = MaterialTheme.typography.button + ) + } + + val restoreLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { viewModel.restoreDatabase(it) } + + if (showPreRestoreDialog) { + PreRestoreDialog( + onConfirm = { + showPreRestoreDialog = false + restoreLauncher.launch(arrayOf("*/*")) + }, + onDismiss = { showPreRestoreDialog = false } + ) + } + + val restoreError = viewModel.restoreError.collectAsStateWithLifecycle().value + + if (restoreError != null) { + RestoreErrorDialog( + error = restoreError, + onDismiss = { viewModel.onRestoreErrorConsumed() }, + ) + } + + if (viewModel.restoreSuccessful.collectAsStateWithLifecycle().value) { + RestoreCompleteDialog() + } + + Spacer(modifier = Modifier.weight(1f)) +} + +@Composable +fun RestoreErrorDialog(error: Int, onDismiss: () -> Unit) = ConfirmDialog( + continueText = R.string.ok, + onConfirm = onDismiss, + onDismissRequest = onDismiss +) { + Text( + text = stringResource(id = error), + style = MaterialTheme.typography.subtitle2, + color = MaterialTheme.colors.onSurface + ) +} + +@Composable +private fun BackupCard(viewModel: BackupAndRestoreViewModel) = Card( + shape = MaterialTheme.shapes.medium +) { + Column( + modifier = Modifier.padding(dimensionResource(id = R.dimen.card_padding_large)) + ) { + Text( + text = stringResource(id = R.string.backup_hint_text), + style = MaterialTheme.typography.subtitle2, + color = MaterialTheme.colors.onSurface + ) + + InputSpacingLarge() + + val autoBackupInfo = viewModel.autoBackupInfo.collectAsStateWithLifecycle().value + + if (autoBackupInfo != null) AutoBackupInfo(autoBackupInfo) + + AutoBackupControls( + Modifier.align(Alignment.CenterHorizontally), + viewModel + ) + + DialogInputSpacing() + + BackupButton( + Modifier.align(Alignment.CenterHorizontally), + viewModel + ) + } +} + +@Composable +private fun ColumnScope.AutoBackupInfo(autoBackupInfo: BackupAndRestoreViewModel.AutoBackupInfo) { + + val context = LocalContext.current + + CenterGradientDivider() + InputSpacingLarge() + val lastSuccessful = autoBackupInfo.lastSuccessful?.let { + formatDayMonthYearHourMinuteWeekDayOneLine(context, getWeekDayNames(context), it) + } ?: stringResource(id = R.string.none) + + Text( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = stringResource(id = R.string.last_successful_backup), + style = MaterialTheme.typography.subtitle2, + color = MaterialTheme.colors.onSurface, + ) + + Text( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = lastSuccessful, + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.onSurface, + ) + + InputSpacingLarge() + + val nextScheduled = autoBackupInfo.nextScheduled?.let { + formatDayMonthYearHourMinuteWeekDayOneLine(context, getWeekDayNames(context), it) + } ?: stringResource(id = R.string.none) + + Text( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = stringResource(id = R.string.next_scheduled_backup), + style = MaterialTheme.typography.subtitle2, + color = MaterialTheme.colors.onSurface, + ) + + Text( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = nextScheduled, + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.onSurface, + ) + + InputSpacingLarge() + CenterGradientDivider() + InputSpacingLarge() +} + +@Composable +private fun BackupButton( + modifier: Modifier, + viewModel: BackupAndRestoreViewModel +) { + val context = LocalContext.current + + val backupLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(SQLITE_MIME_TYPE) + ) { viewModel.exportDatabase(it) } + + Button( + modifier = modifier, + onClick = { + val now = OffsetDateTime.now() + val generatedName = context.getString( + R.string.backup_file_name_suffix, + "TrackAndGraphBackup", now.year, now.monthValue, + now.dayOfMonth, now.hour, now.minute, now.second + ) + backupLauncher.launch(generatedName) + }) { + Text( + text = stringResource(id = R.string.backup_now).uppercase(), + style = MaterialTheme.typography.button + ) + } +} + +@SuppressLint("InlinedApi") +@Composable +private fun AutoBackupControls(modifier: Modifier, viewModel: BackupAndRestoreViewModel) = Row( + modifier = modifier + .wrapContentWidth() + .border( + width = 1.dp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f), + shape = MaterialTheme.shapes.medium + ) + .padding(start = dimensionResource(id = R.dimen.card_padding)), + verticalAlignment = Alignment.CenterVertically +) { + + Text( + text = stringResource(id = R.string.auto_backup).uppercase(), + style = MaterialTheme.typography.button + ) + + DialogInputSpacing() + + var showConfigureAutoBackupDialog by rememberSaveable { mutableStateOf(false) } + + val autoBackupEnabled by viewModel.autoBackupEnabled.collectAsStateWithLifecycle() + + Checkbox( + checked = autoBackupEnabled, + onCheckedChange = { + if (it) showConfigureAutoBackupDialog = true + else viewModel.disableAutoBackup() + } + ) + + if (autoBackupEnabled) { + IconButton(onClick = { showConfigureAutoBackupDialog = true }) { + Icon( + painter = painterResource(id = R.drawable.edit_icon), + contentDescription = stringResource(id = R.string.edit) + ) + } + } + + val autoBackupViewModel = hiltViewModel() + + var showNotificationPermissionPromptDialog by remember { mutableStateOf(false) } + + val context = LocalContext.current + + if (showConfigureAutoBackupDialog) { + ConfigureAutoBackupDialog( + viewModel = autoBackupViewModel, + onConfirm = { + showConfigureAutoBackupDialog = false + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (!hasNotificationPermission(context)) { + showNotificationPermissionPromptDialog = true + } + } + + autoBackupViewModel.onConfirmAutoBackup() + }, + onDismiss = { + showConfigureAutoBackupDialog = false + autoBackupViewModel.onCancelConfig() + } + ) + } + + val permissionRequestLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { } + + if (showNotificationPermissionPromptDialog) { + NotificationPermissionPromptDialog( + onConfirm = { + showNotificationPermissionPromptDialog = false + permissionRequestLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) + }, + onDismiss = { + showNotificationPermissionPromptDialog = false + permissionRequestLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) + } + ) + } +} + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +private fun hasNotificationPermission(context: Context): Boolean { + return ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED +} + +@Composable +private fun NotificationPermissionPromptDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit +) = ConfirmDialog( + onConfirm = onConfirm, + onDismissRequest = onDismiss, + continueText = R.string.ok +) { + Text( + text = stringResource(id = R.string.notification_permission_prompt), + style = MaterialTheme.typography.subtitle2, + color = MaterialTheme.colors.onSurface + ) +} + +@Composable +private fun PreRestoreDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit +) = ConfirmDialog( + onConfirm = onConfirm, + onDismissRequest = onDismiss +) { + Text( + text = stringResource(id = R.string.restore_hint_text), + style = MaterialTheme.typography.subtitle2, + color = MaterialTheme.colors.onSurface, + ) +} + +@Composable +private fun RestoreCompleteDialog() = ConfirmDialog( + onConfirm = { exitProcess(0) }, + dismissOnClickOutside = false, + onDismissRequest = { }, + continueText = R.string.ok +) { + Text( + text = stringResource(id = R.string.restore_successful), + style = MaterialTheme.typography.subtitle2, + color = MaterialTheme.colors.onSurface + ) +} + +@Composable +private fun CenterGradientDivider() = BoxWithConstraints( + Modifier + .fillMaxWidth() + .height(2.dp) +) { + val aspectRatio = maxWidth / maxHeight + Box( + Modifier + .fillMaxSize() + .scale(maxOf(aspectRatio, 1f), maxOf(1 / aspectRatio, 1f)) + .background( + brush = Brush.radialGradient( + colors = listOf( + MaterialTheme.colors.secondary, + MaterialTheme.colors.surface.copy(alpha = 0.0f) + ), + ), + ) + ) +} + +@Composable +private fun ConfigureAutoBackupDialog( + viewModel: AutoBackupViewModel, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.autoBackupConfigFailures.collect { + Toast.makeText(context, it, Toast.LENGTH_LONG).show() + } + } + + CustomConfirmCancelDialog( + onDismissRequest = onDismiss, + customWidthPercentage = 0.9f, + onConfirm = onConfirm, + continueText = R.string.apply, + continueEnabled = viewModel.autoBackupConfigValid.collectAsStateWithLifecycle().value, + ) { + AutoBackupInnerLayout(viewModel = viewModel) + } +} + +@Composable +private fun AutoBackupInnerLayout(viewModel: AutoBackupViewModel) = Box( + modifier = Modifier + .heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.8f) +) { + Column( + Modifier.verticalScroll(rememberScrollState()) + ) { + val location = viewModel.autoBackupLocation.collectAsStateWithLifecycle().value + + val selectFileLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(SQLITE_MIME_TYPE) + ) { viewModel.onBackupLocationChanged(it) } + + val defaultAutoBackupFileName = stringResource(id = R.string.default_auto_backup_file_name) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.backup_location), + style = MaterialTheme.typography.subtitle2.copy(textAlign = TextAlign.Center), + ) + + DialogInputSpacing() + + if (location != null) { + Text( + text = location, + style = MaterialTheme.typography.body2.copy(textAlign = TextAlign.Center), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + DialogInputSpacing() + } + + Button( + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = { selectFileLauncher.launch(defaultAutoBackupFileName) }, + ) { + + val text = if (location == null) + stringResource(id = R.string.select_location) + else stringResource(id = R.string.select_new_location) + + Text( + text = text, + style = MaterialTheme.typography.button + ) + } + + InputSpacingLarge() + Divider() + InputSpacingLarge() + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.backup_every), + style = MaterialTheme.typography.subtitle2.copy(textAlign = TextAlign.Center), + ) + + DialogInputSpacing() + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + + Spacer(modifier = Modifier.weight(1f)) + + MiniNumericTextField( + modifier = Modifier.width(64.dp), + textFieldValue = viewModel.autoBackupIntervalTextFieldValue.value, + onValueChange = viewModel::onBackupIntervalChanged, + textAlign = TextAlign.Center + ) + + DialogInputSpacing() + + val strings = mapOf( + ChronoUnit.HOURS to stringResource(id = R.string.hours_generic), + ChronoUnit.DAYS to stringResource(id = R.string.days_generic), + ChronoUnit.WEEKS to stringResource(id = R.string.weeks_generic), + ) + + TextMapSpinner( + modifier = Modifier.width(128.dp), + strings = strings, + selectedItem = viewModel.autoBackupUnit.collectAsStateWithLifecycle().value, + onItemSelected = { viewModel.onBackupUnitChanged(it) } + ) + + Spacer(modifier = Modifier.weight(1f)) + } + + InputSpacingLarge() + Divider() + InputSpacingLarge() + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.next_backup_at), + style = MaterialTheme.typography.subtitle2.copy(textAlign = TextAlign.Center), + ) + + DialogInputSpacing() + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + DateButton( + dateTime = viewModel.autoBackupFirstDate.collectAsStateWithLifecycle().value, + onDateSelected = viewModel::onAutoBackupFirstDateChanged, + allowPastDates = false + ) + + DialogInputSpacing() + + TimeButton( + dateTime = viewModel.autoBackupFirstDate.collectAsStateWithLifecycle().value, + onTimeSelected = viewModel::onAutoBackupFirstDateChanged, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/backupandrestore/BackupAndRestoreViewModel.kt b/app/src/main/java/com/samco/trackandgraph/backupandrestore/BackupAndRestoreViewModel.kt new file mode 100644 index 000000000..c028efcd3 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/backupandrestore/BackupAndRestoreViewModel.kt @@ -0,0 +1,213 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.samco.trackandgraph.backupandrestore + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.samco.trackandgraph.R +import com.samco.trackandgraph.backupandrestore.dto.BackupResult +import com.samco.trackandgraph.backupandrestore.dto.RestoreResult +import com.samco.trackandgraph.base.model.di.IODispatcher +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.threeten.bp.OffsetDateTime +import javax.inject.Inject + +interface BackupAndRestoreViewModel { + + data class AutoBackupInfo( + val lastSuccessful: OffsetDateTime?, + val nextScheduled: OffsetDateTime? + ) + + val backupError: StateFlow + val backupSuccessful: StateFlow + val restoreError: StateFlow + val restoreSuccessful: StateFlow + val autoBackupEnabled: StateFlow + val autoBackupInfo: StateFlow + val inProgress: StateFlow + + fun onBackupSuccessfulConsumed() + fun onBackupErrorConsumed() + fun onRestoreErrorConsumed() + fun exportDatabase(uri: Uri?) + fun restoreDatabase(uri: Uri?) + fun disableAutoBackup() +} + +@HiltViewModel +class BackupAndRestoreViewModelImpl @Inject constructor( + private val backupRestoreInteractor: BackupRestoreInteractor, + @IODispatcher private val io: CoroutineDispatcher +) : ViewModel(), BackupAndRestoreViewModel { + + private sealed class OperationState { + object Idle : OperationState() + object InProgress : OperationState() + object Success : OperationState() + data class Error(val stringResource: Int) : OperationState() + } + + private val onBackupRequest = MutableSharedFlow(extraBufferCapacity = 1) + private val onRestoreRequested = MutableSharedFlow(extraBufferCapacity = 1) + + private val backupState = merge( + onRestoreRequested.map { OperationState.Idle }, + onBackupRequest + .flatMapLatest { uri -> + flow { + emit(OperationState.InProgress) + + if (uri == null) { + emit(OperationState.Error(R.string.backup_error_could_not_write_to_file)) + return@flow + } + + when (backupRestoreInteractor.performManualBackup(uri)) { + BackupResult.SUCCESS -> emit(OperationState.Success) + + BackupResult.FAIL_COULD_NOT_WRITE_TO_FILE -> + emit(OperationState.Error(R.string.backup_error_could_not_write_to_file)) + + BackupResult.FAIL_COULD_NOT_FIND_DATABASE -> + emit(OperationState.Error(R.string.backup_error_could_not_find_database_file)) + + BackupResult.FAIL_COULD_NOT_COPY -> + emit(OperationState.Error(R.string.backup_error_failed_to_copy_database)) + } + } + } + .flowOn(io) + ) + .stateIn(viewModelScope, SharingStarted.Eagerly, OperationState.Idle) + + override val autoBackupEnabled: StateFlow = backupRestoreInteractor + .autoBackupConfig + .map { it != null } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + override val autoBackupInfo: StateFlow = + backupRestoreInteractor.autoBackupConfig + .map { config -> + config?.let { + BackupAndRestoreViewModel.AutoBackupInfo( + lastSuccessful = it.lastSuccessful, + nextScheduled = it.nextScheduled + ) + } + } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + private val restoreState = merge( + onBackupRequest.map { OperationState.Idle }, + onRestoreRequested + .flatMapLatest { uri -> + flow { + emit(OperationState.InProgress) + + if (uri == null) { + emit(OperationState.Error(R.string.restore_error_could_not_read_from_database_file)) + return@flow + } + + when (backupRestoreInteractor.performManualRestore(uri)) { + RestoreResult.SUCCESS -> emit(OperationState.Success) + + RestoreResult.FAIL_INVALID_DATABASE -> + emit(OperationState.Error(R.string.restore_error_invalid_database_file)) + + RestoreResult.FAIL_COULD_NOT_FIND_OR_READ_DATABASE_FILE -> + emit(OperationState.Error(R.string.restore_error_could_not_read_from_database_file)) + + RestoreResult.FAIL_COULD_NOT_COPY -> + emit(OperationState.Error(R.string.restore_error_failed_to_copy_database)) + } + } + } + .flowOn(io) + ) + .stateIn(viewModelScope, SharingStarted.Eagerly, OperationState.Idle) + + private val onBackupSuccessfulConsumed = MutableSharedFlow(extraBufferCapacity = 1) + private val onBackupErrorConsumed = MutableSharedFlow(extraBufferCapacity = 1) + private val onRestoreErrorConsumed = MutableSharedFlow(extraBufferCapacity = 1) + + override val backupError: StateFlow = + merge( + onBackupErrorConsumed.map { null }, + backupState.mapNotNull { (it as? OperationState.Error)?.stringResource }, + ).stateIn(viewModelScope, SharingStarted.Eagerly, null) + + override val backupSuccessful: StateFlow = merge( + onBackupSuccessfulConsumed.map { false }, + backupState.mapNotNull { (it is OperationState.Success) }, + ).stateIn(viewModelScope, SharingStarted.Eagerly, false) + + override val restoreError: StateFlow = + merge( + onRestoreErrorConsumed.map { null }, + restoreState.mapNotNull { (it as? OperationState.Error)?.stringResource }, + ).stateIn(viewModelScope, SharingStarted.Eagerly, null) + + override val restoreSuccessful: StateFlow = merge( + restoreState.mapNotNull { (it is OperationState.Success) }, + ).stateIn(viewModelScope, SharingStarted.Eagerly, false) + + override val inProgress = combine(restoreState, backupState) { restore, backup -> + restore == OperationState.InProgress || backup == OperationState.InProgress + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + override fun onBackupSuccessfulConsumed() { + viewModelScope.launch { onBackupSuccessfulConsumed.emit(Unit) } + } + + override fun onBackupErrorConsumed() { + viewModelScope.launch { onBackupErrorConsumed.emit(Unit) } + } + + override fun onRestoreErrorConsumed() { + viewModelScope.launch { onRestoreErrorConsumed.emit(Unit) } + } + + override fun exportDatabase(uri: Uri?) { + viewModelScope.launch { onBackupRequest.emit(uri) } + } + + override fun restoreDatabase(uri: Uri?) { + viewModelScope.launch { onRestoreRequested.emit(uri) } + } + + override fun disableAutoBackup() = backupRestoreInteractor.disableAutoBackup() +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/backupandrestore/BackupRestoreInteractor.kt b/app/src/main/java/com/samco/trackandgraph/backupandrestore/BackupRestoreInteractor.kt new file mode 100644 index 000000000..16daf3bf4 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/backupandrestore/BackupRestoreInteractor.kt @@ -0,0 +1,44 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.backupandrestore + +import android.net.Uri +import com.samco.trackandgraph.backupandrestore.dto.AutoBackupInfo +import com.samco.trackandgraph.backupandrestore.dto.BackupConfig +import com.samco.trackandgraph.backupandrestore.dto.BackupResult +import com.samco.trackandgraph.backupandrestore.dto.RestoreResult +import kotlinx.coroutines.flow.Flow + +interface BackupRestoreInteractor { + + val autoBackupConfig: Flow + + suspend fun performManualBackup(uri: Uri): BackupResult + + suspend fun performManualRestore(uri: Uri): RestoreResult + + suspend fun performAutoBackup(): BackupResult + + suspend fun backupNowAndSetAutoBackupConfig(backupConfig: BackupConfig): BackupResult + + fun validAutoBackupConfiguration(backupConfig: BackupConfig): Boolean + + suspend fun getAutoBackupInfo(): AutoBackupInfo? + + fun disableAutoBackup() +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/backupandrestore/BackupRestoreInteractorImpl.kt b/app/src/main/java/com/samco/trackandgraph/backupandrestore/BackupRestoreInteractorImpl.kt new file mode 100644 index 000000000..03cff48d8 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/backupandrestore/BackupRestoreInteractorImpl.kt @@ -0,0 +1,259 @@ +package com.samco.trackandgraph.backupandrestore + +import android.content.Context +import android.content.Intent +import android.database.sqlite.SQLiteDatabase +import android.net.Uri +import androidx.sqlite.db.SimpleSQLiteQuery +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import com.samco.trackandgraph.backupandrestore.dto.AutoBackupInfo +import com.samco.trackandgraph.backupandrestore.dto.BackupConfig +import com.samco.trackandgraph.backupandrestore.dto.BackupResult +import com.samco.trackandgraph.backupandrestore.dto.RestoreResult +import com.samco.trackandgraph.base.database.TNG_DATABASE_VERSION +import com.samco.trackandgraph.base.database.TrackAndGraphDatabase +import com.samco.trackandgraph.base.helpers.PrefHelper +import com.samco.trackandgraph.base.model.AlarmInteractor +import com.samco.trackandgraph.base.model.di.IODispatcher +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.withContext +import org.threeten.bp.Instant +import org.threeten.bp.OffsetDateTime +import org.threeten.bp.ZoneId +import org.threeten.bp.temporal.ChronoUnit +import timber.log.Timber +import java.io.File +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlin.math.max + +class BackupRestoreInteractorImpl @Inject constructor( + private val database: TrackAndGraphDatabase, + private val alarmInteractor: AlarmInteractor, + @ApplicationContext private val context: Context, + private val prefHelper: PrefHelper, + @IODispatcher private val io: CoroutineDispatcher +) : BackupRestoreInteractor { + + private val backupConfigChanged = MutableSharedFlow(extraBufferCapacity = 1) + + override val autoBackupConfig: Flow = backupConfigChanged + .onStart { emit(Unit) } + .map { getAutoBackupInfo() } + + override suspend fun performManualBackup(uri: Uri): BackupResult { + val writableDatabase = database.openHelper.writableDatabase + val databaseFile = writableDatabase.path + ?.let { path -> File(path).takeIf { it.exists() } } + ?: return BackupResult.FAIL_COULD_NOT_FIND_DATABASE + + val outputStream = try { + context.contentResolver.openOutputStream(uri, "wt") + } catch (t: Throwable) { + return BackupResult.FAIL_COULD_NOT_WRITE_TO_FILE + } + + outputStream?.use { outStream -> + try { + writableDatabase.query(SimpleSQLiteQuery("PRAGMA wal_checkpoint(full)")).apply { + moveToFirst() + if (getInt(0) != 0) return BackupResult.FAIL_COULD_NOT_COPY + close() + } + + databaseFile.inputStream().use { it.copyTo(outStream) } + + return BackupResult.SUCCESS + } catch (t: Throwable) { + Timber.e(t, "Error backing up database") + return BackupResult.FAIL_COULD_NOT_COPY + } + } ?: return BackupResult.FAIL_COULD_NOT_WRITE_TO_FILE + } + + override suspend fun performManualRestore(uri: Uri): RestoreResult { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + try { + val tempFile = File.createTempFile("dbrestore", "db") + + tempFile.outputStream().use { tempOutStream -> + inputStream.use { it.copyTo(tempOutStream) } + } + + if (!isValidDatabase(tempFile)) { + return RestoreResult.FAIL_INVALID_DATABASE + } + + alarmInteractor.clearAlarms() + database.openHelper.close() + + tempFile.inputStream().use { + val databaseFileOutputStream = database.openHelper.writableDatabase.path + ?.let { path -> File(path).takeIf { it.exists() } } + ?.outputStream() + ?: return RestoreResult.FAIL_COULD_NOT_COPY + + it.copyTo(databaseFileOutputStream) + } + + tempFile.deleteOnExit() + + return RestoreResult.SUCCESS + } catch (t: Throwable) { + Timber.e(t, "Error clearing alarms before restore") + return RestoreResult.FAIL_COULD_NOT_COPY + } + } ?: return RestoreResult.FAIL_COULD_NOT_FIND_OR_READ_DATABASE_FILE + } + + private fun isValidDatabase(dbFile: File): Boolean { + var db: SQLiteDatabase? = null + try { + db = SQLiteDatabase.openDatabase( + dbFile.path, + null, + SQLiteDatabase.OPEN_READONLY + ) + + if (db.version > TNG_DATABASE_VERSION) return false + } catch (t: Throwable) { + return false + } finally { + db?.close() + } + return true + } + + override suspend fun performAutoBackup(): BackupResult { + val uri = prefHelper.getAutoBackupConfig()?.uri + ?: return BackupResult.FAIL_COULD_NOT_WRITE_TO_FILE + return performManualBackup(uri).also { + if (it == BackupResult.SUCCESS) prefHelper.setLastAutoBackupTime(OffsetDateTime.now()) + backupConfigChanged.emit(Unit) + } + } + + private fun validUnit(unit: ChronoUnit) = + setOf( + ChronoUnit.HOURS, + ChronoUnit.DAYS, + ChronoUnit.WEEKS + ).contains(unit) + + override fun validAutoBackupConfiguration(backupConfig: BackupConfig): Boolean { + return backupConfig.firstDate.isAfter(OffsetDateTime.now()) && + backupConfig.interval > 0 && + validUnit(backupConfig.units) + } + + companion object { + private const val AUTO_BACKUP_WORK_NAME = "auto_backup" + } + + override suspend fun backupNowAndSetAutoBackupConfig(backupConfig: BackupConfig): BackupResult { + try { + context.contentResolver.takePersistableUriPermission( + backupConfig.uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + + prefHelper.setAutoBackupConfig(backupConfig.asPrefHelperData()) + + val backupResult = performAutoBackup() + + if (backupResult != BackupResult.SUCCESS) { + prefHelper.setAutoBackupConfig(null) + return backupResult + } + + setupWorkManager(backupConfig) + + backupConfigChanged.tryEmit(Unit) + return backupResult + } catch (t: Throwable) { + Timber.e(t, "Error setting auto backup config") + return BackupResult.FAIL_COULD_NOT_WRITE_TO_FILE + } + } + + private fun setupWorkManager(backupConfig: BackupConfig) { + val unit = when (backupConfig.units) { + ChronoUnit.HOURS -> TimeUnit.HOURS + ChronoUnit.DAYS -> TimeUnit.DAYS + ChronoUnit.WEEKS -> TimeUnit.DAYS + else -> throw IllegalArgumentException("Invalid unit") + } + + val interval = when (backupConfig.units) { + ChronoUnit.WEEKS -> backupConfig.interval.toLong() * 7 + ChronoUnit.HOURS, + ChronoUnit.DAYS -> backupConfig.interval.toLong() + + else -> throw IllegalArgumentException("Invalid unit") + } + + val secondsDelay = max( + 10L, + backupConfig.firstDate.toEpochSecond() - OffsetDateTime.now().toEpochSecond() + ) + + val periodicWorkRequest = + PeriodicWorkRequestBuilder(interval, unit) + .setInitialDelay(secondsDelay, TimeUnit.SECONDS) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + AUTO_BACKUP_WORK_NAME, + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + periodicWorkRequest + ) + } + + override suspend fun getAutoBackupInfo(): AutoBackupInfo? { + val config = getAutoBackupConfiguration() ?: return null + + val nextScheduledMillis = withContext(io) { + WorkManager.getInstance(context) + .getWorkInfosForUniqueWork(AUTO_BACKUP_WORK_NAME) + .get() + ?.let { workInfos -> + workInfos + .firstOrNull() + ?.takeIf { !it.state.isFinished } + ?.nextScheduleTimeMillis + } + } ?: return null + + val nextScheduledOdt = OffsetDateTime.ofInstant( + Instant.ofEpochMilli(nextScheduledMillis), + ZoneId.systemDefault() + ) + + val lastSuccessful = prefHelper.getLastAutoBackupTime() + + return AutoBackupInfo( + uri = config.uri, + nextScheduled = nextScheduledOdt, + interval = config.interval, + units = config.units, + lastSuccessful = lastSuccessful + ) + } + + private fun getAutoBackupConfiguration(): BackupConfig? { + return prefHelper.getAutoBackupConfig()?.let { BackupConfig(it) } + } + + override fun disableAutoBackup() { + prefHelper.setAutoBackupConfig(null) + WorkManager.getInstance(context).cancelUniqueWork(AUTO_BACKUP_WORK_NAME) + backupConfigChanged.tryEmit(Unit) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/backupandrestore/PeriodicBackupWorker.kt b/app/src/main/java/com/samco/trackandgraph/backupandrestore/PeriodicBackupWorker.kt new file mode 100644 index 000000000..d019b8193 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/backupandrestore/PeriodicBackupWorker.kt @@ -0,0 +1,134 @@ +package com.samco.trackandgraph.backupandrestore + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import com.samco.trackandgraph.backupandrestore.dto.BackupResult +import com.samco.trackandgraph.base.R +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import timber.log.Timber + +@HiltWorker +class PeriodicBackupWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted workerParameters: WorkerParameters, + private val interactor: BackupRestoreInteractor +) : CoroutineWorker(context, workerParameters) { + + companion object { + private const val AUTO_BACKUP_NOTIFICATION_CHANNEL_ID = "auto_backup_channel" + //Hardcoded ids because we only have one notification (there is no significance to this number) + private const val AUTO_BACKUP_FAIL_NOTIFICATION_ID = 51235 + private const val AUTO_BACKUP_NOTIFICATION_ID = 51234 + } + + override suspend fun doWork(): Result { + Timber.d("Performing auto backup") + try { + //TODO I don't actually recall seeing this work in a long time. Might not work on later + // versions of Android? + setForeground(createForegroundInfo()) + } catch (t: Throwable) { + //This can happen sometimes when the app is in the background + Timber.e(t, "setForeground failed") + } + + return when (val result = interactor.performAutoBackup()) { + BackupResult.SUCCESS -> { + Timber.d("Auto backup successful") + Result.success() + } + BackupResult.FAIL_COULD_NOT_WRITE_TO_FILE, + BackupResult.FAIL_COULD_NOT_FIND_DATABASE, + BackupResult.FAIL_COULD_NOT_COPY -> { + showFailNotification(result) + Result.failure() + } + } + } + + private fun showFailNotification(result: BackupResult) { + val channelId = AUTO_BACKUP_NOTIFICATION_CHANNEL_ID + val title = applicationContext.getString(R.string.backup_failed_notification_title) + + // Create a Notification channel if necessary + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createChannel() + } + + val contentText = when (result) { + BackupResult.FAIL_COULD_NOT_COPY, + BackupResult.FAIL_COULD_NOT_FIND_DATABASE -> applicationContext + .getString(R.string.backup_failed_notification_could_not_copy) + + BackupResult.FAIL_COULD_NOT_WRITE_TO_FILE -> applicationContext + .getString(R.string.backup_failed_notification_could_not_write_to_file) + + else -> throw IllegalArgumentException("Invalid result") + } + + val notification = NotificationCompat.Builder(applicationContext, channelId) + .setContentTitle(title) + .setContentText(contentText) + .setTicker(title) + .setSmallIcon(R.drawable.notification_icon) + .build() + + val notificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.notify(AUTO_BACKUP_FAIL_NOTIFICATION_ID, notification) + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + return createForegroundInfo() + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createChannel() { + val id = AUTO_BACKUP_NOTIFICATION_CHANNEL_ID + val name = applicationContext.getString(R.string.backup_notification_channel_name) + val importance = NotificationManager.IMPORTANCE_LOW + val channel = NotificationChannel(id, name, importance).apply { + description = applicationContext.getString( + R.string.backup_notification_channel_description + ) + } + (applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).apply { + createNotificationChannel(channel) + } + } + + private fun createForegroundInfo(): ForegroundInfo { + val channelId = AUTO_BACKUP_NOTIFICATION_CHANNEL_ID + val title = applicationContext.getString(R.string.backup_notification_title) + + // Create a Notification channel if necessary + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createChannel() + } + + val notification = NotificationCompat.Builder(applicationContext, channelId) + .setSilent(true) + .setOngoing(true) + .setContentTitle(title) + .setTicker(title) + .setSmallIcon(R.drawable.notification_icon) + .build() + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo(AUTO_BACKUP_NOTIFICATION_ID, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(AUTO_BACKUP_NOTIFICATION_ID, notification) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/backupandrestore/dto/AutoBackupInfo.kt b/app/src/main/java/com/samco/trackandgraph/backupandrestore/dto/AutoBackupInfo.kt new file mode 100644 index 000000000..b816e703a --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/backupandrestore/dto/AutoBackupInfo.kt @@ -0,0 +1,13 @@ +package com.samco.trackandgraph.backupandrestore.dto + +import android.net.Uri +import org.threeten.bp.OffsetDateTime +import org.threeten.bp.temporal.ChronoUnit + +data class AutoBackupInfo( + val uri: Uri, + val nextScheduled: OffsetDateTime, + val interval: Int, + val units: ChronoUnit, + val lastSuccessful: OffsetDateTime? +) \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/backupandrestore/dto/BackupConfig.kt b/app/src/main/java/com/samco/trackandgraph/backupandrestore/dto/BackupConfig.kt new file mode 100644 index 000000000..5046bdea9 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/backupandrestore/dto/BackupConfig.kt @@ -0,0 +1,45 @@ +package com.samco.trackandgraph.backupandrestore.dto + +import android.net.Uri +import com.samco.trackandgraph.base.helpers.PrefHelper +import org.threeten.bp.OffsetDateTime +import org.threeten.bp.temporal.ChronoUnit + +data class BackupConfig( + val uri: Uri, + val firstDate: OffsetDateTime, + val interval: Int, + val units: ChronoUnit +) { + companion object { + private fun chronoUnitToPrefUnit(chronoUnit: ChronoUnit): PrefHelper.BackupConfigUnit = + when (chronoUnit) { + ChronoUnit.HOURS -> PrefHelper.BackupConfigUnit.HOURS + ChronoUnit.DAYS -> PrefHelper.BackupConfigUnit.DAYS + ChronoUnit.WEEKS -> PrefHelper.BackupConfigUnit.WEEKS + else -> throw IllegalArgumentException("Invalid unit") + } + + private fun prefUnitToChronoUnit(prefUnit: PrefHelper.BackupConfigUnit): ChronoUnit = + when (prefUnit) { + PrefHelper.BackupConfigUnit.HOURS -> ChronoUnit.HOURS + PrefHelper.BackupConfigUnit.DAYS -> ChronoUnit.DAYS + PrefHelper.BackupConfigUnit.WEEKS -> ChronoUnit.WEEKS + } + } + + constructor(prefHelperData: PrefHelper.BackupConfigData) : this( + uri = prefHelperData.uri, + firstDate = prefHelperData.firstDate, + interval = prefHelperData.interval, + units = prefUnitToChronoUnit(prefHelperData.units) + ) + + fun asPrefHelperData() = PrefHelper.BackupConfigData( + uri = uri, + firstDate = firstDate, + interval = interval, + units = chronoUnitToPrefUnit(units) + ) + +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/backupandrestore/dto/BackupResult.kt b/app/src/main/java/com/samco/trackandgraph/backupandrestore/dto/BackupResult.kt new file mode 100644 index 000000000..90d2817ef --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/backupandrestore/dto/BackupResult.kt @@ -0,0 +1,8 @@ +package com.samco.trackandgraph.backupandrestore.dto + +enum class BackupResult { + SUCCESS, + FAIL_COULD_NOT_WRITE_TO_FILE, + FAIL_COULD_NOT_FIND_DATABASE, + FAIL_COULD_NOT_COPY +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/backupandrestore/dto/RestoreResult.kt b/app/src/main/java/com/samco/trackandgraph/backupandrestore/dto/RestoreResult.kt new file mode 100644 index 000000000..c46dca17c --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/backupandrestore/dto/RestoreResult.kt @@ -0,0 +1,8 @@ +package com.samco.trackandgraph.backupandrestore.dto + +enum class RestoreResult { + SUCCESS, + FAIL_INVALID_DATABASE, + FAIL_COULD_NOT_FIND_OR_READ_DATABASE_FILE, + FAIL_COULD_NOT_COPY +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/database/AverageTimeBetweenStat.kt b/app/src/main/java/com/samco/trackandgraph/database/AverageTimeBetweenStat.kt deleted file mode 100644 index 6fbcf30c0..000000000 --- a/app/src/main/java/com/samco/trackandgraph/database/AverageTimeBetweenStat.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.samco.trackandgraph.database - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.PrimaryKey -import org.threeten.bp.Duration - -@Entity(tableName = "average_time_between_stat_table", - foreignKeys = [ - ForeignKey( - entity = GraphOrStat::class, - parentColumns = arrayOf("id"), - childColumns = arrayOf("graph_stat_id"), - onDelete = ForeignKey.CASCADE), - ForeignKey( - entity = Feature::class, - parentColumns = arrayOf("id"), - childColumns = arrayOf("feature_id"), - onDelete = ForeignKey.CASCADE - ) - ] -) -data class AverageTimeBetweenStat( - @PrimaryKey(autoGenerate = true) - @ColumnInfo(name = "id", index = true) - val id: Long, - - @ColumnInfo(name = "graph_stat_id", index = true) - val graphStatId: Long, - - @ColumnInfo(name = "feature_id", index = true) - val featureId: Long, - - @ColumnInfo(name = "from_value") - val fromValue: String, - - @ColumnInfo(name = "to_value") - val toValue: String, - - @ColumnInfo(name = "duration") - val duration: Duration? -) - diff --git a/app/src/main/java/com/samco/trackandgraph/database/DataPoint.kt b/app/src/main/java/com/samco/trackandgraph/database/DataPoint.kt deleted file mode 100644 index fb8ad49ca..000000000 --- a/app/src/main/java/com/samco/trackandgraph/database/DataPoint.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.samco.trackandgraph.database - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import org.threeten.bp.OffsetDateTime - -@Entity(tableName = "data_points_table", - primaryKeys = ["timestamp", "feature_id"], - foreignKeys = [ - androidx.room.ForeignKey( - entity = Feature::class, - parentColumns = arrayOf("id"), - childColumns = arrayOf("feature_id"), - onDelete = ForeignKey.CASCADE - ) - ] -) -data class DataPoint ( - @ColumnInfo(name = "timestamp") - val timestamp: OffsetDateTime = OffsetDateTime.now(), - - @ColumnInfo(name = "feature_id", index = true) - val featureId: Long, - - //TODO should we convert this to a number? - @ColumnInfo(name = "value") - val value: String, - - @ColumnInfo(name = "label") - val label: String - -) { - fun getDisplayTimestamp(): String = displayFeatureDateFormat.format(timestamp) - fun getDisplayValue(): String { - var ans = value - if (label.isNotEmpty()) ans += " : $label" - return ans - } -} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/database/DisplayFeature.kt b/app/src/main/java/com/samco/trackandgraph/database/DisplayFeature.kt deleted file mode 100644 index 1377b6449..000000000 --- a/app/src/main/java/com/samco/trackandgraph/database/DisplayFeature.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.samco.trackandgraph.database - -import androidx.room.ColumnInfo -import org.threeten.bp.OffsetDateTime - -data class DisplayFeature( - @ColumnInfo(name = "id") - var id: Long, - - @ColumnInfo(name = "name") - val name: String, - - @ColumnInfo(name = "track_group_id") - val trackGroupId: Long, - - @ColumnInfo(name = "type") - val featureType: FeatureType = FeatureType.CONTINUOUS, - - @ColumnInfo(name = "discrete_values") - val discreteValues: List, - - @ColumnInfo(name = "last_timestamp") - val timestamp: OffsetDateTime?, - - @ColumnInfo(name = "num_data_points") - val numDataPoints: Long?, - - @ColumnInfo(name = "display_index") - val displayIndex: Int -) { - fun getDisplayTimestamp() = timestamp?.let { displayFeatureDateFormat.format(timestamp) } -} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/database/Feature.kt b/app/src/main/java/com/samco/trackandgraph/database/Feature.kt deleted file mode 100644 index d934ce91e..000000000 --- a/app/src/main/java/com/samco/trackandgraph/database/Feature.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.samco.trackandgraph.database - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.PrimaryKey -import java.lang.Exception - -@Entity(tableName = "features_table", - foreignKeys = [ForeignKey(entity = TrackGroup::class, - parentColumns = arrayOf("id"), - childColumns = arrayOf("track_group_id"), - onDelete = ForeignKey.CASCADE)] -) -data class Feature ( - @PrimaryKey(autoGenerate = true) - @ColumnInfo(name = "id", index = true) - val id: Long, - - @ColumnInfo(name = "name") - val name: String, - - @ColumnInfo(name = "track_group_id", index = true) - val trackGroupId: Long, - - @ColumnInfo(name = "type") - val featureType: FeatureType, - - @ColumnInfo(name = "discrete_values") - val discreteValues: List, - - @ColumnInfo(name = "display_index") - val displayIndex: Int -) { - companion object { - fun create(id: Long, name: String, trackGroupId: Long, featureType: FeatureType, - discreteValues: List, displayIndex: Int): Feature { - discreteValues.forEach { dv -> validateDiscreteValue(dv) } - val validName = name - .take(MAX_FEATURE_NAME_LENGTH) - .replace(splitChars1, " ") - .replace(splitChars2, " ") - return Feature(id, validName, trackGroupId, featureType, discreteValues, displayIndex) - } - } -} - -enum class FeatureType { DISCRETE, CONTINUOUS } - -data class DiscreteValue (val index: Int, val label: String) { - override fun toString() = "$index:$label" - companion object { - fun fromString(value: String): DiscreteValue { - if (!value.contains(':')) throw Exception("value did not contain a colon") - val label = value.substring(value.indexOf(':')+1).trim() - val index = value.substring(0, value.indexOf(':')).trim().toInt() - val discreteValue = DiscreteValue(index, label) - validateDiscreteValue(discreteValue) - return discreteValue - } - fun fromDataPoint(dataPoint: DataPoint) = DiscreteValue(dataPoint.value.toInt(), dataPoint.label) - } -} - -fun validateDiscreteValue(discreteValue: DiscreteValue) { - if (discreteValue.label.contains(splitChars1) || discreteValue.label.contains(splitChars2)) - throw Exception("Illegal discrete value name") - if (discreteValue.label.length > MAX_LABEL_LENGTH) - throw Exception("label size exceeded the maximum size allowed") -} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/database/FeatureAndTrackGroup.kt b/app/src/main/java/com/samco/trackandgraph/database/FeatureAndTrackGroup.kt deleted file mode 100644 index d69392ab1..000000000 --- a/app/src/main/java/com/samco/trackandgraph/database/FeatureAndTrackGroup.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.samco.trackandgraph.database - -import androidx.room.ColumnInfo - -data class FeatureAndTrackGroup( - @ColumnInfo(name = "id") - var id: Long, - - @ColumnInfo(name = "name") - val name: String, - - @ColumnInfo(name = "track_group_id") - val trackGroupId: Long, - - @ColumnInfo(name = "display_index") - val displayIndex: Int, - - @ColumnInfo(name = "type") - val featureType: FeatureType = FeatureType.CONTINUOUS, - - @ColumnInfo(name = "discrete_values") - val discreteValues: List, - - @ColumnInfo(name = "track_group_name") - val trackGroupName: String -) diff --git a/app/src/main/java/com/samco/trackandgraph/database/GraphOrStat.kt b/app/src/main/java/com/samco/trackandgraph/database/GraphOrStat.kt deleted file mode 100644 index e92a33656..000000000 --- a/app/src/main/java/com/samco/trackandgraph/database/GraphOrStat.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.samco.trackandgraph.database - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.PrimaryKey - -enum class GraphStatType { LINE_GRAPH, PIE_CHART, AVERAGE_TIME_BETWEEN, TIME_SINCE } - -@Entity(tableName = "graphs_and_stats_table", - foreignKeys = [ForeignKey(entity = GraphStatGroup::class, - parentColumns = arrayOf("id"), - childColumns = arrayOf("graph_stat_group_id"), - onDelete = ForeignKey.CASCADE)] -) -data class GraphOrStat( - @PrimaryKey(autoGenerate = true) - @ColumnInfo(name = "id", index = true) - val id: Long, - - @ColumnInfo(name = "graph_stat_group_id", index = true) - val graphStatGroupId: Long, - - @ColumnInfo(name = "name") - val name: String, - - @ColumnInfo(name = "graph_stat_type") - val type: GraphStatType, - - @ColumnInfo(name = "display_index") - val displayIndex: Int -) { - companion object { - fun create(id: Long, graphStatGroupId: Long, name: String, - type: GraphStatType, displayIndex: Int): GraphOrStat { - val validName = name.take(MAX_GRAPH_STAT_NAME_LENGTH) - .replace(splitChars1, " ") - .replace(splitChars2, " ") - return GraphOrStat(id, graphStatGroupId, validName, type, displayIndex) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/database/GraphStatGroup.kt b/app/src/main/java/com/samco/trackandgraph/database/GraphStatGroup.kt deleted file mode 100644 index bac15d960..000000000 --- a/app/src/main/java/com/samco/trackandgraph/database/GraphStatGroup.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.samco.trackandgraph.database - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey - -@Entity(tableName = "graph_stat_groups_table") -data class GraphStatGroup( - @PrimaryKey(autoGenerate = true) - @ColumnInfo(name = "id", index = true) - val id: Long, - - @ColumnInfo(name = "name") - val name: String, - - @ColumnInfo(name = "display_index") - val displayIndex: Int -) { - companion object { - fun create(id: Long, name: String, displayIndex: Int): GraphStatGroup { - val validName = name.take(MAX_GRAPH_STAT_GROUP_NAME_LENGTH) - return GraphStatGroup(id, validName, displayIndex) - } - } -} diff --git a/app/src/main/java/com/samco/trackandgraph/database/LineGraph.kt b/app/src/main/java/com/samco/trackandgraph/database/LineGraph.kt deleted file mode 100644 index 886f7e717..000000000 --- a/app/src/main/java/com/samco/trackandgraph/database/LineGraph.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.samco.trackandgraph.database - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.PrimaryKey -import org.threeten.bp.Duration -import org.threeten.bp.Period - -enum class LineGraphAveraginModes { - NO_AVERAGING, - DAILY_MOVING_AVERAGE, - THREE_DAY_MOVING_AVERAGE, - WEEKLY_MOVING_AVERAGE, - MONTHLY_MOVING_AVERAGE, - THREE_MONTH_MOVING_AVERAGE, - SIX_MONTH_MOVING_AVERAGE, - YEARLY_MOVING_AVERAGE -} - -enum class LineGraphPlottingModes { - WHEN_TRACKED, - GENERATE_DAILY_TOTALS, - GENERATE_WEEKLY_TOTALS, - GENERATE_MONTHLY_TOTALS, - GENERATE_YEARLY_TOTALS -} - -val plottingModePeriods = mapOf( - LineGraphPlottingModes.WHEN_TRACKED to null, - LineGraphPlottingModes.GENERATE_DAILY_TOTALS to Period.ofDays(1), - LineGraphPlottingModes.GENERATE_WEEKLY_TOTALS to Period.ofWeeks(1), - LineGraphPlottingModes.GENERATE_MONTHLY_TOTALS to Period.ofMonths(1), - LineGraphPlottingModes.GENERATE_YEARLY_TOTALS to Period.ofYears(1) -) - -val movingAverageDurations = mapOf( - LineGraphAveraginModes.NO_AVERAGING to null, - LineGraphAveraginModes.DAILY_MOVING_AVERAGE to Duration.ofDays(1), - LineGraphAveraginModes.THREE_DAY_MOVING_AVERAGE to Duration.ofDays(3), - LineGraphAveraginModes.WEEKLY_MOVING_AVERAGE to Duration.ofDays(7), - LineGraphAveraginModes.MONTHLY_MOVING_AVERAGE to Duration.ofDays(31), - LineGraphAveraginModes.THREE_MONTH_MOVING_AVERAGE to Duration.ofDays(93), - LineGraphAveraginModes.SIX_MONTH_MOVING_AVERAGE to Duration.ofDays(183), - LineGraphAveraginModes.YEARLY_MOVING_AVERAGE to Duration.ofDays(365) -) - -data class LineGraphFeature( - var featureId: Long, - var name: String, - var colorIndex: Int, - var averagingMode : LineGraphAveraginModes, - var plottingMode: LineGraphPlottingModes, - var offset: Double, - var scale: Double -) - -@Entity(tableName = "line_graphs_table", - foreignKeys = [ForeignKey(entity = GraphOrStat::class, - parentColumns = arrayOf("id"), - childColumns = arrayOf("graph_stat_id"), - onDelete = ForeignKey.CASCADE)] -) -data class LineGraph( - @PrimaryKey(autoGenerate = true) - @ColumnInfo(name = "id", index = true) - val id: Long, - - @ColumnInfo(name = "graph_stat_id", index = true) - val graphStatId: Long, - - @ColumnInfo(name = "features") - val features: List, - - @ColumnInfo(name = "duration") - val duration: Duration? -) { - companion object { - fun create(id: Long, graphStatId: Long, features: List, duration: Duration? ): LineGraph { - val validFeatures = features - .take(MAX_LINE_GRAPH_FEATURES) - .map { f -> validateLineGraphFeature(f) } - return LineGraph(id, graphStatId, validFeatures, duration) - } - - private fun validateLineGraphFeature(f: LineGraphFeature): LineGraphFeature { - return f.copy(name = f.name - .take(MAX_LINE_GRAPH_FEATURE_NAME_LENGTH) - .replace(splitChars1, " ") - .replace(splitChars2, " ") - ) - } - } -} diff --git a/app/src/main/java/com/samco/trackandgraph/database/PieChart.kt b/app/src/main/java/com/samco/trackandgraph/database/PieChart.kt deleted file mode 100644 index 881e3be8d..000000000 --- a/app/src/main/java/com/samco/trackandgraph/database/PieChart.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.samco.trackandgraph.database - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.PrimaryKey -import org.threeten.bp.Duration - -@Entity(tableName = "pie_chart_table", - foreignKeys = [ - ForeignKey( - entity = GraphOrStat::class, - parentColumns = arrayOf("id"), - childColumns = arrayOf("graph_stat_id"), - onDelete = ForeignKey.CASCADE), - ForeignKey( - entity = Feature::class, - parentColumns = arrayOf("id"), - childColumns = arrayOf("feature_id"), - onDelete = ForeignKey.CASCADE - ) - ] -) -data class PieChart( - @PrimaryKey(autoGenerate = true) - @ColumnInfo(name = "id", index = true) - val id: Long, - - @ColumnInfo(name = "graph_stat_id", index = true) - val graphStatId: Long, - - @ColumnInfo(name = "feature_id", index = true) - val featureId: Long, - - @ColumnInfo(name = "duration") - val duration: Duration? -) diff --git a/app/src/main/java/com/samco/trackandgraph/database/TimeSinceLastStat.kt b/app/src/main/java/com/samco/trackandgraph/database/TimeSinceLastStat.kt deleted file mode 100644 index dbcf5566b..000000000 --- a/app/src/main/java/com/samco/trackandgraph/database/TimeSinceLastStat.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.samco.trackandgraph.database - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.PrimaryKey - -@Entity(tableName = "time_since_last_stat_table", - foreignKeys = [ - ForeignKey( - entity = GraphOrStat::class, - parentColumns = arrayOf("id"), - childColumns = arrayOf("graph_stat_id"), - onDelete = ForeignKey.CASCADE), - ForeignKey( - entity = Feature::class, - parentColumns = arrayOf("id"), - childColumns = arrayOf("feature_id"), - onDelete = ForeignKey.CASCADE - ) - ] -) -data class TimeSinceLastStat( - @PrimaryKey(autoGenerate = true) - @ColumnInfo(name = "id", index = true) - val id: Long, - - @ColumnInfo(name = "graph_stat_id", index = true) - val graphStatId: Long, - - @ColumnInfo(name = "feature_id", index = true) - val featureId: Long, - - @ColumnInfo(name = "from_value") - val fromValue: String, - - @ColumnInfo(name = "to_value") - val toValue: String -) diff --git a/app/src/main/java/com/samco/trackandgraph/database/TrackAndGraphDatabase.kt b/app/src/main/java/com/samco/trackandgraph/database/TrackAndGraphDatabase.kt deleted file mode 100644 index 44bb92841..000000000 --- a/app/src/main/java/com/samco/trackandgraph/database/TrackAndGraphDatabase.kt +++ /dev/null @@ -1,141 +0,0 @@ -package com.samco.trackandgraph.database - -import android.content.Context -import androidx.room.* -import com.samco.trackandgraph.R -import org.threeten.bp.Duration -import org.threeten.bp.OffsetDateTime -import org.threeten.bp.ZoneId -import org.threeten.bp.format.DateTimeFormatter - -const val MAX_FEATURE_NAME_LENGTH = 40 -const val MAX_LABEL_LENGTH = 30 -const val MAX_DISCRETE_VALUES_PER_FEATURE = 10 -const val MAX_GRAPH_STAT_NAME_LENGTH = 100 -const val MAX_GRAPH_STAT_GROUP_NAME_LENGTH = 40 -const val MAX_TRACK_GROUP_NAME_LENGTH = 40 -const val MAX_LINE_GRAPH_FEATURE_NAME_LENGTH = 20 -const val MAX_LINE_GRAPH_FEATURES = 10 - -val databaseFormatter: DateTimeFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME -val displayFeatureDateFormat: DateTimeFormatter = DateTimeFormatter - .ofPattern("dd/MM/yy HH:mm") - .withZone(ZoneId.systemDefault()) - - -//this is a number coprime to the number of colours used to select them in a pseudo random order for greater contrast -const val dataVisColorGenerator = 7 -val dataVisColorList = listOf( - R.color.visColor1, - R.color.visColor2, - R.color.visColor3, - R.color.visColor4, - R.color.visColor5, - R.color.visColor6, - R.color.visColor7, - R.color.visColor8, - R.color.visColor9, - R.color.visColor10 -) - -const val splitChars1 = "||" -const val splitChars2 = "!!" - -@Database( - entities = [TrackGroup::class, Feature::class, DataPoint::class, GraphStatGroup::class, - GraphOrStat::class, LineGraph::class, AverageTimeBetweenStat::class, PieChart::class, - TimeSinceLastStat::class], - version = 28, - exportSchema = false -) -@TypeConverters(Converters::class) -abstract class TrackAndGraphDatabase : RoomDatabase() { - abstract val trackAndGraphDatabaseDao: TrackAndGraphDatabaseDao - - companion object { - @Volatile - private var INSTANCE: TrackAndGraphDatabase? = null - fun getInstance(context: Context): TrackAndGraphDatabase { - synchronized(this) { - var instance = INSTANCE - if (instance == null) { - instance = Room.databaseBuilder(context.applicationContext, TrackAndGraphDatabase::class.java, "trackandgraph_database") - .fallbackToDestructiveMigration() - .build() - INSTANCE = instance - } - return instance - } - } - } -} - -class Converters { - @TypeConverter - fun stringToListOfDiscreteValues(value: String): List { - return if (value.isEmpty()) listOf() - else value.split(splitChars1).map { s -> DiscreteValue.fromString(s) }.toList() - } - - @TypeConverter - fun listOfDiscreteValuesToString(values: List): String = values.joinToString(splitChars1) { v -> v.toString() } - - @TypeConverter - fun intToFeatureType(i: Int): FeatureType = FeatureType.values()[i] - - @TypeConverter - fun featureTypeToInt(featureType: FeatureType): Int = featureType.ordinal - - @TypeConverter - fun intToGraphStatType(i: Int): GraphStatType = GraphStatType.values()[i] - - @TypeConverter - fun graphStatTypeToInt(graphStat: GraphStatType): Int = graphStat.ordinal - - @TypeConverter - fun stringToOffsetDateTime(value: String?): OffsetDateTime? = value?.let { odtFromString(value) } - - @TypeConverter - fun offsetDateTimeToString(value: OffsetDateTime?): String = value?.let { stringFromOdt(it) } ?: "" - - @TypeConverter - fun lineGraphFeaturesToString(value: List): String { - return value.joinToString(splitChars1){ v -> - listOf("${v.featureId}", - v.name, - "${v.colorIndex}", - "${v.averagingMode.ordinal}", - "${v.plottingMode.ordinal}", - "${v.offset}", - "${v.scale}" - ).joinToString(splitChars2) - } - } - - @TypeConverter - fun stringToLineGraphFeatures(value: String): List { - return value.split(splitChars1).map { - val strs = it.split(splitChars2) - LineGraphFeature( - strs[0].toLong(), - strs[1], - strs[2].toInt(), - LineGraphAveraginModes.values()[strs[3].toInt()], - LineGraphPlottingModes.values()[strs[4].toInt()], - strs[5].toDouble(), - strs[6].toDouble() - ) - } - } - - @TypeConverter - fun durationToString(value: Duration?): String = value?.let { value.toString() } ?: "" - - @TypeConverter - fun stringToDuration(value: String): Duration? = if (value.isEmpty()) null else Duration.parse(value) -} - - -fun odtFromString(value: String): OffsetDateTime = databaseFormatter.parse(value, OffsetDateTime::from) -fun stringFromOdt(value: OffsetDateTime): String = databaseFormatter.format(value) - diff --git a/app/src/main/java/com/samco/trackandgraph/database/TrackAndGraphDatabaseDao.kt b/app/src/main/java/com/samco/trackandgraph/database/TrackAndGraphDatabaseDao.kt deleted file mode 100644 index 0160e7ada..000000000 --- a/app/src/main/java/com/samco/trackandgraph/database/TrackAndGraphDatabaseDao.kt +++ /dev/null @@ -1,188 +0,0 @@ -package com.samco.trackandgraph.database - -import androidx.lifecycle.LiveData -import androidx.room.* -import org.threeten.bp.OffsetDateTime - -@Dao -interface TrackAndGraphDatabaseDao { - @Insert - fun insertTrackGroup(trackGroup: TrackGroup): Long - - @Delete - fun deleteTrackGroup(trackGroup: TrackGroup) - - @Query("SELECT * FROM track_groups_table ORDER BY display_index ASC") - fun getTrackGroups() : LiveData> - - @Query("SELECT * FROM graph_stat_groups_table ORDER BY display_index ASC") - fun getGraphStatGroups() : LiveData> - - @Delete - fun deleteGraphStatGroup(graphStatGroup: GraphStatGroup) - - @Update - fun updateGraphStatGroup(graphStatGroup: GraphStatGroup) - - @Update - fun updateGraphStatGroups(graphStatGroups: List) - - @Insert - fun insertGraphStatGroup(graphStatGroup: GraphStatGroup) - - @Query("SELECT * FROM track_groups_table WHERE id = :id LIMIT 1") - fun getTrackGroupById(id: Int) : TrackGroup - - @Update - fun updateTrackGroup(trackGroup: TrackGroup) - - @Update - fun updateTrackGroups(trackGroups: List) - - @Update - fun updateFeatures(features: List) - - @Query("""SELECT features_table.*, num_data_points, last_timestamp from features_table - LEFT JOIN ( - SELECT feature_id as id, COUNT(*) as num_data_points, MAX(timestamp) as last_timestamp - FROM data_points_table GROUP BY feature_id - ) as feature_data - ON feature_data.id = features_table.id - WHERE track_group_id = :trackGroupId - ORDER BY features_table.display_index ASC, id DESC""") - fun getDisplayFeaturesForTrackGroup(trackGroupId: Long): LiveData> - - @Query("SELECT features_table.* FROM features_table WHERE track_group_id = :trackGroupId ORDER BY features_table.display_index ASC") - fun getFeaturesForTrackGroupSync(trackGroupId: Long): List - - @Query("""SELECT * FROM features_table WHERE id = :featureId LIMIT 1""") - fun getFeatureById(featureId: Long): Feature - - @Query("""SELECT * FROM features_table WHERE id = :featureId LIMIT 1""") - fun tryGetFeatureById(featureId: Long): Feature? - - @Query("""SELECT features_table.*, track_groups_table.id as track_group_id, track_groups_table.name as track_group_name - FROM features_table - LEFT JOIN track_groups_table - ON features_table.track_group_id = track_groups_table.id - ORDER BY track_groups_table.display_index ASC, features_table.display_index ASC, features_table.id ASC""") - fun getAllFeaturesAndTrackGroups(): LiveData> - - @Query("""SELECT features_table.*, track_groups_table.id as track_group_id, track_groups_table.name as track_group_name - FROM features_table - LEFT JOIN track_groups_table - ON features_table.track_group_id = track_groups_table.id - WHERE features_table.id = :featureId LIMIT 1""") - fun getFeatureAndTrackGroupByFeatureId(featureId: Long): FeatureAndTrackGroup? - - @Query("""SELECT * from features_table WHERE id IN (:featureIds) ORDER BY display_index ASC, id DESC""") - fun getFeaturesByIdsSync(featureIds: List): List - - @Insert - fun insertFeature(feature: Feature): Long - - @Update - fun updateFeature(feature: Feature) - - @Delete - fun deleteFeature(feature: Feature) - - @Delete - fun deleteDataPoint(dataPoint: DataPoint) - - @Delete - fun deleteGraphOrStat(graphOrStat: GraphOrStat) - - @Query("DELETE FROM features_table WHERE id = :id") - fun deleteFeature(id: Long) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertDataPoint(dataPoint: DataPoint): Long - - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertDataPoints(dataPoint: List) - - @Update - fun updateDataPoint(dataPoint: DataPoint) - - @Query("""SELECT * FROM data_points_table WHERE feature_id = :featureId AND value >= :min AND value <= :max ORDER BY timestamp""") - fun getDataPointsBetween(featureId: Long, min: String, max: String): List - - @Query("""SELECT * FROM data_points_table WHERE feature_id = :featureId AND value >= :min AND value <= :max AND timestamp < :now AND timestamp > :cutOff ORDER BY timestamp""") - fun getDataPointsBetweenInTimeRange(featureId: Long, min: String, max: String, cutOff: OffsetDateTime, now: OffsetDateTime): List - - @Query("""SELECT * FROM data_points_table WHERE feature_id = :featureId AND value >= :min AND value <= :max ORDER BY timestamp DESC LIMIT 1""") - fun getLastDataPointBetween(featureId: Long, min: String, max: String): DataPoint? - - @Query("""SELECT * FROM data_points_table WHERE feature_id = :featureId AND value = :value ORDER BY timestamp DESC LIMIT 1""") - fun getLastDataPointWithValue(featureId: Long, value: String): DataPoint? - - @Query("SELECT * FROM data_points_table WHERE feature_id = :featureId ORDER BY timestamp DESC") - fun getDataPointsForFeatureSync(featureId: Long): List - - @Query("SELECT * FROM data_points_table WHERE feature_id = :featureId AND timestamp > :cutOff AND timestamp < :now ORDER BY timestamp ASC") - fun getDataPointsForFeatureBetweenAscSync(featureId: Long, cutOff: OffsetDateTime, now: OffsetDateTime): List - - @Query("SELECT * FROM data_points_table WHERE feature_id = :featureId ORDER BY timestamp ASC") - fun getDataPointsForFeatureAscSync(featureId: Long): List - - @Query("SELECT * FROM data_points_table WHERE feature_id = :featureId ORDER BY timestamp DESC") - fun getDataPointsForFeature(featureId: Long): LiveData> - - @Query("SELECT * FROM data_points_table WHERE feature_id = :featureId AND timestamp = :timestamp") - fun getDataPointByTimestampAndFeatureSync(featureId: Long, timestamp: OffsetDateTime): DataPoint - - @Query("SELECT * FROM graphs_and_stats_table WHERE id = :graphStatId LIMIT 1") - fun getGraphStatById(graphStatId: Long): GraphOrStat? - - @Query("SELECT * FROM line_graphs_table WHERE graph_stat_id = :graphStatId LIMIT 1") - fun getLineGraphByGraphStatId(graphStatId: Long): LineGraph? - - @Query("SELECT * FROM pie_chart_table WHERE graph_stat_id = :graphStatId LIMIT 1") - fun getPieChartByGraphStatId(graphStatId: Long): PieChart? - - @Query("SELECT * FROM average_time_between_stat_table WHERE graph_stat_id = :graphStatId LIMIT 1") - fun getAverageTimeBetweenStatByGraphStatId(graphStatId: Long): AverageTimeBetweenStat? - - @Query("SELECT * FROM time_since_last_stat_table WHERE graph_stat_id = :graphStatId LIMIT 1") - fun getTimeSinceLastStatByGraphStatId(graphStatId: Long): TimeSinceLastStat? - - @Query("SELECT * FROM graphs_and_stats_table WHERE graph_stat_group_id = :graphStatGroupId ORDER BY display_index ASC") - fun getGraphsAndStatsByGroupId(graphStatGroupId: Long): LiveData> - - @Query("SELECT * FROM graphs_and_stats_table ORDER BY display_index ASC") - fun getAllGraphStatsSync(): List - - @Insert - fun insertLineGraph(lineGraph: LineGraph): Long - - @Update - fun updateLineGraph(lineGraph: LineGraph) - - @Insert - fun insertPieChart(pieChart: PieChart): Long - - @Update - fun updatePieChart(pieChart: PieChart) - - @Insert - fun insertAverageTimeBetweenStat(averageTimeBetweenStat: AverageTimeBetweenStat): Long - - @Update - fun updateAverageTimeBetweenStat(averageTimeBetweenStat: AverageTimeBetweenStat) - - @Insert - fun insertTimeSinceLastStat(timeSinceLastStat: TimeSinceLastStat): Long - - @Update - fun updateTimeSinceLastStat(timeSinceLastStat: TimeSinceLastStat) - - @Insert - fun insertGraphOrStat(graphOrStat: GraphOrStat): Long - - @Update - fun updateGraphOrStat(graphOrStat: GraphOrStat) - - @Update - fun updateGraphStats(graphStat: List) -} diff --git a/app/src/main/java/com/samco/trackandgraph/database/TrackGroup.kt b/app/src/main/java/com/samco/trackandgraph/database/TrackGroup.kt deleted file mode 100644 index c641da764..000000000 --- a/app/src/main/java/com/samco/trackandgraph/database/TrackGroup.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.samco.trackandgraph.database - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey - -@Entity(tableName = "track_groups_table") -data class TrackGroup( - @PrimaryKey(autoGenerate = true) - @ColumnInfo(name = "id", index = true) - val id: Long, - - @ColumnInfo(name = "name") - val name: String, - - @ColumnInfo(name = "display_index") - val displayIndex: Int -) { - companion object { - fun create(id: Long, name: String, displayIndex: Int): TrackGroup { - val validName = name.take(MAX_TRACK_GROUP_NAME_LENGTH) - .replace(splitChars1, " ") - .replace(splitChars2, " ") - return TrackGroup(id, validName, displayIndex) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/di/AppModule.kt b/app/src/main/java/com/samco/trackandgraph/di/AppModule.kt new file mode 100644 index 000000000..47077eef9 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/di/AppModule.kt @@ -0,0 +1,66 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.di + +import android.content.ContentResolver +import android.content.Context +import com.samco.trackandgraph.adddatapoint.SuggestedValueHelper +import com.samco.trackandgraph.adddatapoint.SuggestedValueHelperImpl +import com.samco.trackandgraph.backupandrestore.BackupRestoreInteractor +import com.samco.trackandgraph.backupandrestore.BackupRestoreInteractorImpl +import com.samco.trackandgraph.base.helpers.PrefHelper +import com.samco.trackandgraph.base.helpers.PrefHelperImpl +import com.samco.trackandgraph.base.navigation.PendingIntentProvider +import com.samco.trackandgraph.navigation.PendingIntentProviderImpl +import com.samco.trackandgraph.settings.TngSettings +import com.samco.trackandgraph.settings.TngSettingsImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class AppModule { + @Provides + fun getContentResolver(@ApplicationContext context: Context): ContentResolver = + context.contentResolver + + @Provides + fun getPendingIntentProvider(impl: PendingIntentProviderImpl): PendingIntentProvider = impl + + @Provides + fun getSuggestedValueHelper(impl: SuggestedValueHelperImpl): SuggestedValueHelper = impl + + @Provides + @Singleton + //Must be singleton because it is a dependency of work manager worker + fun getPrefHelper(impl: PrefHelperImpl): PrefHelper = impl + + @Provides + @Singleton + fun getTngSettings(impl: TngSettingsImpl): TngSettings = impl + + @Provides + @Singleton + //Must be singleton because it is a dependency of work manager worker + fun getBackupRestoreInteractor(impl: BackupRestoreInteractorImpl): BackupRestoreInteractor = impl + +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/di/ViewModelModule.kt b/app/src/main/java/com/samco/trackandgraph/di/ViewModelModule.kt new file mode 100644 index 000000000..77be4de7b --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/di/ViewModelModule.kt @@ -0,0 +1,35 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.di + +import com.samco.trackandgraph.graphstatproviders.GraphStatInteractorProvider +import com.samco.trackandgraph.graphstatproviders.GraphStatInteractorProviderImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped + +@Module +@InstallIn(ActivityRetainedComponent::class) +class ViewModelModule { + @Provides + @ActivityRetainedScoped + fun getGraphStatInteractorProvider(impl: GraphStatInteractorProviderImpl) + : GraphStatInteractorProvider = impl +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/AddFeatureFragment.kt b/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/AddFeatureFragment.kt deleted file mode 100644 index 91a665f78..000000000 --- a/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/AddFeatureFragment.kt +++ /dev/null @@ -1,269 +0,0 @@ -package com.samco.trackandgraph.displaytrackgroup - -import android.app.Activity -import android.os.Bundle -import android.text.InputFilter -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.* -import androidx.databinding.DataBindingUtil -import androidx.fragment.app.Fragment -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProviders -import androidx.navigation.NavController -import androidx.navigation.findNavController -import androidx.navigation.fragment.navArgs -import androidx.room.withTransaction -import com.samco.trackandgraph.database.* -import com.samco.trackandgraph.databinding.AddFeatureFragmentBinding -import com.samco.trackandgraph.databinding.FeatureDiscreteValueListItemBinding -import kotlinx.coroutines.* -import android.view.inputmethod.InputMethodManager -import androidx.core.view.forEachIndexed -import androidx.core.view.size -import androidx.core.widget.addTextChangedListener -import com.samco.trackandgraph.R - - -class AddFeatureFragment : Fragment(), AdapterView.OnItemSelectedListener { - private val args: AddFeatureFragmentArgs by navArgs() - private lateinit var binding: AddFeatureFragmentBinding - private lateinit var viewModel: AddFeatureViewModel - private var navController: NavController? = null - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - viewModel = ViewModelProviders.of(this).get(AddFeatureViewModel::class.java) - binding = DataBindingUtil.inflate(inflater, R.layout.add_feature_fragment, container, false) - navController = container?.findNavController() - - binding.discreteValuesTextView.visibility = View.INVISIBLE - binding.discreteValues.visibility = View.INVISIBLE - binding.addDiscreteValueButton.visibility = View.INVISIBLE - binding.discreteValuesTipText.visibility = View.INVISIBLE - binding.featureNameText.setSelection(binding.featureNameText.text.length) - binding.featureNameText.filters = arrayOf(InputFilter.LengthFilter(MAX_FEATURE_NAME_LENGTH)) - binding.featureNameText.addTextChangedListener { - viewModel.featureName.value = binding.featureNameText.text.toString() - validateForm() - } - binding.featureNameText.requestFocus() - binding.featureTypeSpinner.onItemSelectedListener = this - binding.addDiscreteValueButton.setOnClickListener { onAddDiscreteValue() } - binding.addBar.addButton.setOnClickListener { onAddClicked() } - listenToViewModelState() - return binding.root - } - - override fun onResume() { - super.onResume() - val imm = activity!!.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput(binding.featureNameText, 0) - } - - override fun onDestroyView() { - super.onDestroyView() - val imm = activity?.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view?.windowToken, 0) - } - - private fun listenToViewModelState() { - viewModel.addFeatureState.observe(this, Observer { state -> - when (state) { - AddFeatureState.WAITING -> binding.progressBar.visibility = View.INVISIBLE - AddFeatureState.ADDING -> { - binding.addBar.addButton.isEnabled = false - binding.progressBar.visibility = View.VISIBLE - } - AddFeatureState.DONE -> navController?.popBackStack() - } - }) - } - - override fun onViewStateRestored(savedInstanceState: Bundle?) { - super.onViewStateRestored(savedInstanceState) - binding.featureNameText.setText(viewModel.featureName.value) - binding.featureTypeSpinner.setSelection(spinnerIndexOf(viewModel.featureType.value!!)) - viewModel.discreteValues.forEach { v -> inflateDiscreteValue(v) } - if (viewModel.discreteValues.size == MAX_DISCRETE_VALUES_PER_FEATURE) - binding.addDiscreteValueButton.isEnabled = false - } - - private fun spinnerIndexOf(featureType: FeatureType): Int = when(featureType) { - FeatureType.CONTINUOUS -> 0 - else -> 1 - } - - private fun inflateDiscreteValue(label: AddFeatureViewModel.MutableLabel) { - val item = FeatureDiscreteValueListItemBinding.inflate(layoutInflater, binding.discreteValues, false) - val inputText = item.discreteValueNameText - inputText.filters = arrayOf(InputFilter.LengthFilter(MAX_LABEL_LENGTH)) - inputText.addTextChangedListener { - label.value = it.toString().trim() - validateForm() - } - inputText.setText(label.value) - item.deleteButton.setOnClickListener { - onDeleteDiscreteValue(item) - validateForm() - } - item.upButton.setOnClickListener { - onUpClickedDiscreteValue(item) - validateForm() - } - item.downButton.setOnClickListener { - onDownClickedDiscreteValue(item) - validateForm() - } - binding.discreteValues.addView(item.root) - binding.scrollView.post { - binding.scrollView.fullScroll(View.FOCUS_DOWN) - inputText.requestFocus() - } - reIndexDiscreteValueViews() - validateForm() - } - - private fun onAddDiscreteValue() { - val label = AddFeatureViewModel.MutableLabel() - viewModel.discreteValues.add(label) - inflateDiscreteValue(label) - if (viewModel.discreteValues.size == MAX_DISCRETE_VALUES_PER_FEATURE) - binding.addDiscreteValueButton.isEnabled = false - } - - private fun validateForm() { - var errorSet = false - var discreteValueStrings = viewModel.discreteValues - if (viewModel.featureType.value!! == FeatureType.DISCRETE) { - if (discreteValueStrings.isNullOrEmpty()) { - setErrorText(getString(R.string.discrete_feature_needs_at_least_one_value)) - errorSet = true - } - for (s in discreteValueStrings) { - if (s.value.isEmpty()) { - setErrorText(getString(R.string.discrete_value_must_have_name)) - errorSet = true - } - } - } - binding.featureNameText.text.let { - if (it.isEmpty()) { - setErrorText(getString(R.string.feature_name_cannot_be_null)) - errorSet = true - } - else if (args.existingFeatureNames.contains(it.toString())) { - setErrorText(getString(R.string.feature_with_that_name_exists)) - errorSet = true - } - } - if (errorSet) { - binding.addBar.addButton.isEnabled = false - } else { - binding.addBar.addButton.isEnabled = true - setErrorText("") - } - } - - private fun setErrorText(text: String) { - binding.addBar.errorText.text = text - } - - private fun onDownClickedDiscreteValue(item: FeatureDiscreteValueListItemBinding) { - val currIndex = binding.discreteValues.indexOfChild(item.root) - if (currIndex == binding.discreteValues.childCount-1) return - viewModel.discreteValues.add(currIndex+1, viewModel.discreteValues.removeAt(currIndex)) - binding.discreteValues.removeView(item.root) - binding.discreteValues.addView(item.root, currIndex+1) - reIndexDiscreteValueViews() - } - - private fun onUpClickedDiscreteValue(item: FeatureDiscreteValueListItemBinding) { - val currIndex = binding.discreteValues.indexOfChild(item.root) - if (currIndex == 0) return - viewModel.discreteValues.add(currIndex-1, viewModel.discreteValues.removeAt(currIndex)) - binding.discreteValues.removeView(item.root) - binding.discreteValues.addView(item.root, currIndex-1) - reIndexDiscreteValueViews() - } - - private fun onDeleteDiscreteValue(item: FeatureDiscreteValueListItemBinding) { - val currIndex = binding.discreteValues.indexOfChild(item.root) - viewModel.discreteValues.removeAt(currIndex) - binding.discreteValues.removeView(item.root) - binding.addDiscreteValueButton.isEnabled = true - reIndexDiscreteValueViews() - } - - private fun reIndexDiscreteValueViews() { - binding.discreteValues.forEachIndexed{ i, v -> - val index = if (binding.discreteValues.size == 1) 1 else i - v.findViewById(R.id.indexText).text = "$index : " - } - } - - override fun onItemSelected(p0: AdapterView<*>?, p1: View?, position: Int, p3: Long) = when(position) { - 0 -> onFeatureTypeSelected(false) - 1 -> onFeatureTypeSelected(true) - else -> {} - } - override fun onNothingSelected(p0: AdapterView<*>?) { } - - private fun onFeatureTypeSelected(discrete: Boolean) { - viewModel.featureType.value = if (discrete) FeatureType.DISCRETE else FeatureType.CONTINUOUS - val vis = if (discrete) View.VISIBLE else View.GONE - binding.discreteValuesTextView.visibility = vis - binding.discreteValues.visibility = vis - binding.addDiscreteValueButton.visibility = vis - binding.discreteValuesTipText.visibility = vis - validateForm() - } - - private fun onAddClicked() { - viewModel.onAddFeature(activity!!, binding.featureNameText.text.toString(), args.trackGroupId) - } -} - -enum class AddFeatureState { WAITING, ADDING, DONE } -class AddFeatureViewModel : ViewModel() { - private var updateJob = Job() - private val uiScope = CoroutineScope(Dispatchers.Main + updateJob) - - class MutableLabel(var value: String = "") - - var featureName = MutableLiveData("") - var featureType = MutableLiveData(FeatureType.CONTINUOUS) - var discreteValues = mutableListOf() - val addFeatureState: LiveData get() { return _isAdding } - private val _isAdding by lazy { - val adding = MutableLiveData() - adding.value = AddFeatureState.WAITING - return@lazy adding - } - - fun onAddFeature(activity: Activity, name: String, trackGroupId: Long) { - val application = activity.application - val database = TrackAndGraphDatabase.getInstance(application) - val dao = database.trackAndGraphDatabaseDao - uiScope.launch { - _isAdding.value = AddFeatureState.ADDING - withContext(Dispatchers.IO) { - val discVals = discreteValues.mapIndexed { i, s -> - val index = if (discreteValues.size == 1) 1 else i - DiscreteValue(index, s.value) - } - val feature = Feature.create(0, name, trackGroupId, featureType.value!!, discVals, 0) - database.withTransaction { dao.insertFeature(feature) } - } - _isAdding.value = AddFeatureState.DONE - } - } - - override fun onCleared() { - super.onCleared() - uiScope.cancel() - } -} diff --git a/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/DataPointInputView.kt b/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/DataPointInputView.kt deleted file mode 100644 index 70261d6f6..000000000 --- a/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/DataPointInputView.kt +++ /dev/null @@ -1,163 +0,0 @@ -package com.samco.trackandgraph.displaytrackgroup - -import android.app.DatePickerDialog -import android.app.TimePickerDialog -import android.content.Context -import android.text.Editable -import android.text.TextWatcher -import android.view.LayoutInflater -import android.view.View -import android.view.inputmethod.EditorInfo -import android.widget.* -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.view.children -import com.samco.trackandgraph.R -import com.samco.trackandgraph.database.DiscreteValue -import com.samco.trackandgraph.database.Feature -import com.samco.trackandgraph.database.FeatureType -import org.threeten.bp.OffsetDateTime -import org.threeten.bp.ZoneId -import org.threeten.bp.format.DateTimeFormatter - -class DataPointInputView(context: Context, private val state: DataPointInputData) - : ConstraintLayout(context), TextWatcher { - - private val titleText: TextView - private val numberInput: EditText - private val dateButton: Button - private val timeButton: Button - private val buttonsScroll: HorizontalScrollView - private val buttonsLayout: LinearLayout - private lateinit var discreteValueCheckBoxes: MutableMap - - private var clickListener: DataPointInputClickListener? = null - - private val dateDisplayFormatter: DateTimeFormatter = DateTimeFormatter - .ofPattern("dd/MM/yyyy") - .withZone(ZoneId.systemDefault()) - - private val timeDisplayFormatter: DateTimeFormatter = DateTimeFormatter - .ofPattern("HH:mm") - .withZone(ZoneId.systemDefault()) - - init { - val view = inflate(context, R.layout.data_point_input_fragment, this) - - numberInput = view.findViewById(R.id.numberInput) - titleText = view.findViewById(R.id.titleText) - dateButton = view.findViewById(R.id.dateButton) - timeButton = view.findViewById(R.id.timeButton) - buttonsScroll = view.findViewById(R.id.buttonsScrollView) - buttonsLayout = view.findViewById(R.id.buttonsLayout) - - titleText.text = state.feature.name - - if (state.dateModifiable) { - initDateButton() - initTimeButton() - setSelectedDateTime(state.dateTime) - } else { - dateButton.visibility = View.INVISIBLE - timeButton.visibility = View.INVISIBLE - } - - if (state.feature.featureType == FeatureType.CONTINUOUS) { - buttonsScroll.visibility = View.GONE - numberInput.visibility = View.VISIBLE - numberInput.requestFocus() - numberInput.addTextChangedListener(this) - numberInput.setOnEditorActionListener { v, i, e -> - return@setOnEditorActionListener if ((i and EditorInfo.IME_MASK_ACTION) != 0) { - clickListener?.onClick?.invoke(state.feature) - true - } else false - } - } else { - buttonsScroll.visibility = View.VISIBLE - numberInput.visibility = View.GONE - } - if (state.feature.featureType == FeatureType.DISCRETE) { - createButtons() - } - - if (state.feature.featureType == FeatureType.CONTINUOUS) { - numberInput.setText(state.value) - } else if (state.label.isNotEmpty()) { - buttonsLayout.children - .map{ v -> v.findViewById(R.id.checkbox) } - .first { cb -> cb.text == state.label } - .isChecked = true - } - } - - override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) { } - override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) { } - override fun afterTextChanged(p0: Editable?) { - val double = numberInput.text.toString().toDoubleOrNull() ?: 0 - state.value = double.toString() - } - - class DataPointInputData( - var feature: Feature, - var dateTime: OffsetDateTime, - var value: String, - var label: String, - var dateModifiable: Boolean - ) - class DataPointInputClickListener(val onClick: (Feature) -> Unit) - fun setOnClickListener(clickListener: DataPointInputClickListener) { this.clickListener = clickListener } - - private fun createButtons() { - discreteValueCheckBoxes = mutableMapOf() - val inflater = LayoutInflater.from(context) - for (discreteValue in state.feature.discreteValues.sortedBy { f -> f.index }) { - val item = inflater.inflate(R.layout.discrete_value_input_button, buttonsLayout, false) as CheckBox - item.text = discreteValue.label - item.setOnClickListener { onDiscreteValueClicked(discreteValue) } - discreteValueCheckBoxes[discreteValue] = item - buttonsLayout.addView(item) - } - } - - private fun onDiscreteValueClicked(discreteValue: DiscreteValue) { - if (clickListener != null) { - state.value = discreteValue.index.toString() - state.label = discreteValue.label - discreteValueCheckBoxes.filter { kvp -> kvp.key != discreteValue }.forEach { kvp -> kvp.value.isChecked = false } - clickListener!!.onClick(state.feature) - } - } - - private fun setSelectedDateTime(dateTime: OffsetDateTime) { - state.dateTime = dateTime - dateButton.text = dateTime.format(dateDisplayFormatter) - timeButton.text = dateTime.format(timeDisplayFormatter) - } - - private fun initDateButton() { - dateButton.setOnClickListener { - val picker = DatePickerDialog(context!!, - DatePickerDialog.OnDateSetListener { _, year, month, day -> - setSelectedDateTime(state.dateTime - .withYear(year) - .withMonth(month+1) - .withDayOfMonth(day)) - }, state.dateTime.year, state.dateTime.monthValue-1, state.dateTime.dayOfMonth - ) - picker.show() - } - } - - private fun initTimeButton() { - timeButton.setOnClickListener { - val picker = TimePickerDialog(context!!, - TimePickerDialog.OnTimeSetListener { _, hour, minute -> - setSelectedDateTime(state.dateTime - .withHour(hour) - .withMinute(minute)) - }, state.dateTime.hour, state.dateTime.minute, true - ) - picker.show() - } - } -} diff --git a/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/DisplayTrackGroupFragment.kt b/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/DisplayTrackGroupFragment.kt deleted file mode 100644 index fe39e9d20..000000000 --- a/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/DisplayTrackGroupFragment.kt +++ /dev/null @@ -1,273 +0,0 @@ -package com.samco.trackandgraph.displaytrackgroup - -import android.app.Activity -import android.os.Bundle -import android.view.* -import androidx.appcompat.app.AppCompatActivity -import androidx.databinding.DataBindingUtil -import androidx.fragment.app.Fragment -import androidx.lifecycle.* -import androidx.navigation.NavController -import androidx.navigation.findNavController -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.RecyclerView -import com.samco.trackandgraph.R -import com.samco.trackandgraph.database.* -import com.samco.trackandgraph.databinding.FragmentDisplayTrackGroupBinding -import com.samco.trackandgraph.ui.YesCancelDialogFragment -import kotlinx.coroutines.* - -const val TRACK_GROUP_ID_KEY = "TRACK_GROUP_ID_KEY" -const val TRACK_GROUP_NAME_KEY = "TRACK_GROUP_NAME_KEY" - -class DisplayTrackGroupFragment : Fragment(), - RenameFeatureDialogFragment.RenameFeatureDialogListener, - YesCancelDialogFragment.YesCancelDialogListener { - - private var navController: NavController? = null - private val args: DisplayTrackGroupFragmentArgs by navArgs() - - private lateinit var binding: FragmentDisplayTrackGroupBinding - private lateinit var adapter: FeatureAdapter - private lateinit var viewModel: DisplayTrackGroupViewModel - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - this.navController = container?.findNavController() - - binding = DataBindingUtil.inflate(inflater, R.layout.fragment_display_track_group, container, false) - binding.lifecycleOwner = viewLifecycleOwner - viewModel = ViewModelProviders.of(this).get(DisplayTrackGroupViewModel::class.java) - viewModel.initViewModel(activity!!, args.trackGroup) - - adapter = FeatureAdapter(FeatureClickListener( - this::onFeatureRenameClicked, - this::onFeatureDeleteClicked, - this::onFeatureAddClicked, - this::onFeatureHistoryClicked - )) - observeFeatureDataAndUpdate(viewModel, adapter) - binding.featureList.adapter = adapter - ItemTouchHelper(getDragTouchHelper()).attachToRecyclerView(binding.featureList) - registerForContextMenu(binding.featureList) - initializeGridLayout() - - binding.queueAddAllButton.hide() - binding.queueAddAllButton.setOnClickListener { onQueueAddAllClicked() } - - setHasOptionsMenu(true) - (activity as AppCompatActivity).supportActionBar?.title = args.trackGroupName - return binding.root - } - - private fun getDragTouchHelper() = object : ItemTouchHelper.Callback() { - override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { - return makeFlag(ItemTouchHelper.ACTION_STATE_DRAG, - ItemTouchHelper.UP or ItemTouchHelper.DOWN - or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) - } - - override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { - adapter.moveItem(viewHolder.adapterPosition, target.adapterPosition) - return true - } - - override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { - super.onSelectedChanged(viewHolder, actionState) - if (viewHolder != null && viewHolder is FeatureViewHolder && actionState == ItemTouchHelper.ACTION_STATE_DRAG) { - viewHolder.elevateCard() - } - } - - override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { - super.clearView(recyclerView, viewHolder) - (viewHolder as FeatureViewHolder).dropCard() - viewModel.adjustDisplayIndexes(adapter.getItems()) - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { } - } - - private fun initializeGridLayout() { - val dm = resources.displayMetrics - val screenWidth = dm.widthPixels / dm.density - val itemSize = 180f - val gridLayout = GridLayoutManager(context, (screenWidth / itemSize).toInt()) - binding.featureList.layoutManager = gridLayout - } - - private fun observeFeatureDataAndUpdate(displayTrackGroupViewModel: DisplayTrackGroupViewModel, adapter: FeatureAdapter) { - displayTrackGroupViewModel.features.observe(viewLifecycleOwner, Observer { - it?.let { - adapter.submitList(it.toMutableList()) - } - if (it.isNullOrEmpty()) { - binding.noFeaturesHintText.text = getString(R.string.no_features_hint) - binding.noFeaturesHintText.visibility = View.VISIBLE - binding.queueAddAllButton.hide() - } else { - binding.queueAddAllButton.show() - binding.noFeaturesHintText.visibility = View.INVISIBLE - } - }) - adapter.registerAdapterDataObserver(object: RecyclerView.AdapterDataObserver() { - override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - binding.featureList.smoothScrollToPosition(0) - } - }) - } - - private fun onAddClicked() { - val featureNames = viewModel.features.value?.map{f -> f.name}?.toTypedArray() ?: arrayOf() - navController?.navigate( - DisplayTrackGroupFragmentDirections - .actionAddFeature(args.trackGroup, featureNames) - ) - } - - override fun getFeature(): Feature { - val f = viewModel.currentActionFeature!! - return Feature.create(f.id, f.name, f.trackGroupId, f.featureType, f.discreteValues, f.displayIndex) - } - - private fun onFeatureDeleteClicked(feature: DisplayFeature) { - viewModel.currentActionFeature = feature - val dialog = YesCancelDialogFragment() - var args = Bundle() - args.putString("title", getString(R.string.ru_sure_del_feature)) - dialog.arguments = args - childFragmentManager.let { dialog.show(it, "ru_sure_del_feature_fragment") } - } - - override fun onDialogYes(dialog: YesCancelDialogFragment) { - when (dialog.title) { - getString(R.string.ru_sure_del_feature) -> onDeleteFeature(viewModel.currentActionFeature!!) - } - } - - //TODO allow people to rename the discrete values if it is a discrete feature - private fun onFeatureRenameClicked(feature: DisplayFeature) { - viewModel.currentActionFeature = feature - val dialog = RenameFeatureDialogFragment() - childFragmentManager.let { dialog.show(it, "rename_feature_dialog") } - } - - override fun getMaxFeatureNameChars(): Int = MAX_FEATURE_NAME_LENGTH - - private fun onDeleteFeature(feature: DisplayFeature) { viewModel.deleteFeature(feature) } - - override fun onRenameFeature(newName: String) { - val f = viewModel.currentActionFeature!! - val newFeature = Feature.create(f.id, newName, f.trackGroupId, - f.featureType, f.discreteValues, f.displayIndex) - viewModel.updateFeature(newFeature) - } - - private fun onFeatureAddClicked(feature: DisplayFeature) { - val argBundle = Bundle() - argBundle.putLongArray(FEATURE_LIST_KEY, longArrayOf(feature.id)) - showAddDataPoint(argBundle) - } - - private fun onQueueAddAllClicked() { - viewModel.features.value?.let { feats -> - if (feats.isEmpty()) return - val argBundle = Bundle() - argBundle.putLongArray(FEATURE_LIST_KEY, feats.map { f -> f.id }.toLongArray()) - showAddDataPoint(argBundle) - } - } - - private fun showAddDataPoint(argBundle: Bundle) { - val dialog = InputDataPointDialog() - dialog.arguments = argBundle - childFragmentManager.let { dialog.show(it, "input_data_points_dialog") } - } - - private fun onFeatureHistoryClicked(feature: DisplayFeature) { - if (feature.numDataPoints == null || feature.numDataPoints <= 0L) return - navController?.navigate( - DisplayTrackGroupFragmentDirections - .actionFeatureHistory(feature.id, feature.name) - ) - } - - private fun onExportClicked() { - val dialog = ExportFeaturesDialog() - val argBundle = Bundle() - argBundle.putLong(TRACK_GROUP_ID_KEY, args.trackGroup) - argBundle.putString(TRACK_GROUP_NAME_KEY, args.trackGroupName) - dialog.arguments = argBundle - childFragmentManager.let { dialog.show(it, "export_features_dialog") } - } - - private fun onImportClicked() { - val dialog = ImportFeaturesDialog() - val argBundle = Bundle() - argBundle.putLong(TRACK_GROUP_ID_KEY, args.trackGroup) - argBundle.putString(TRACK_GROUP_NAME_KEY, args.trackGroupName) - dialog.arguments = argBundle - childFragmentManager.let { dialog.show(it, "import_features_dialog") } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.add -> onAddClicked() - R.id.exportButton -> onExportClicked() - R.id.importButton -> onImportClicked() - } - return super.onOptionsItemSelected(item) - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.display_track_group_menu, menu) - super.onCreateOptionsMenu(menu, inflater) - } -} - -class DisplayTrackGroupViewModel : ViewModel() { - private var dataSource: TrackAndGraphDatabaseDao? = null - private var trackGroupId: Long = -1 - - var currentActionFeature: DisplayFeature? = null - lateinit var features: LiveData> - private set - - private var updateJob = Job() - private val ioScope = CoroutineScope(Dispatchers.IO + updateJob) - - fun initViewModel(activity: Activity, trackGroupId: Long) { - if (dataSource != null) return - this.trackGroupId = trackGroupId - dataSource = TrackAndGraphDatabase.getInstance(activity.application).trackAndGraphDatabaseDao - features = dataSource!!.getDisplayFeaturesForTrackGroup(trackGroupId) - } - - override fun onCleared() { - super.onCleared() - updateJob.cancel() - } - - fun deleteFeature(feature: DisplayFeature) = ioScope.launch { - dataSource?.deleteFeature(feature.id) - } - - fun updateFeature(feature: Feature) = ioScope.launch { - dataSource?.updateFeature(feature) - } - - fun adjustDisplayIndexes(displayFeatures: List) { - ioScope.launch { - features.value?.let { oldList -> - val newList = displayFeatures.mapIndexed { i, df -> - toFeature(oldList.first { f -> f.id == df.id }.copy(displayIndex = i)) - } - dataSource!!.updateFeatures(newList) - } - } - } - - private fun toFeature(df: DisplayFeature) = Feature.create(df.id, df.name, df.trackGroupId, - df.featureType, df.discreteValues, df.displayIndex) -} diff --git a/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/FeatureAdapter.kt b/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/FeatureAdapter.kt deleted file mode 100644 index d4a837d47..000000000 --- a/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/FeatureAdapter.kt +++ /dev/null @@ -1,102 +0,0 @@ -package com.samco.trackandgraph.displaytrackgroup - -import android.annotation.SuppressLint -import android.view.LayoutInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.widget.PopupMenu -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.samco.trackandgraph.R -import com.samco.trackandgraph.database.DisplayFeature -import com.samco.trackandgraph.databinding.ListItemFeatureBinding -import com.samco.trackandgraph.ui.OrderedListAdapter - -class FeatureAdapter(private val clickListener: FeatureClickListener) - : OrderedListAdapter(DisplayFeatureDiffCallback()) { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FeatureViewHolder { - return FeatureViewHolder.from(parent) - } - - override fun onBindViewHolder(holder: FeatureViewHolder, position: Int) { - holder.bind(getItem(position), clickListener) - } -} - -class FeatureViewHolder private constructor(private val binding: ListItemFeatureBinding) - : RecyclerView.ViewHolder(binding.root), PopupMenu.OnMenuItemClickListener { - - private var clickListener: FeatureClickListener? = null - private var feature: DisplayFeature? = null - private var dropElevation = 0f - - fun bind(feature: DisplayFeature, clickListener: FeatureClickListener) { - this.feature = feature - this.clickListener = clickListener - this.dropElevation = binding.cardView.cardElevation - binding.feature = feature - binding.clickListener = clickListener - binding.menuButton.setOnClickListener { createContextMenu(binding.menuButton) } - binding.addButton.setOnClickListener { clickListener.onAdd(feature) } - binding.cardView.setOnClickListener { clickListener.onHistory(feature) } - } - - fun elevateCard() { - binding.cardView.postDelayed({ - binding.cardView.cardElevation = binding.cardView.cardElevation * 3f - }, 10) - } - - fun dropCard() { - binding.cardView.cardElevation = dropElevation - } - - private fun createContextMenu(view: View) { - val popup = PopupMenu(view.context, view) - popup.menuInflater.inflate(R.menu.edit_feature_context_menu, popup.menu) - popup.setOnMenuItemClickListener(this) - popup.show() - } - - override fun onMenuItemClick(item: MenuItem?): Boolean { - feature?.let { - when (item?.itemId) { - R.id.rename -> clickListener?.onRename(it) - R.id.delete -> clickListener?.onDelete(it) - else -> {} - } - } - return false - } - - companion object { - fun from(parent: ViewGroup): FeatureViewHolder { - val layoutInflater = LayoutInflater.from(parent.context) - val binding = ListItemFeatureBinding.inflate(layoutInflater, parent, false) - return FeatureViewHolder(binding) - } - } -} - -class DisplayFeatureDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: DisplayFeature, newItem: DisplayFeature): Boolean { - return oldItem.id == newItem.id - } - @SuppressLint("DiffUtilEquals") - override fun areContentsTheSame(oldItem: DisplayFeature, newItem: DisplayFeature): Boolean { - return oldItem == newItem - } -} - -class FeatureClickListener(private val onRenameListener: (feature: DisplayFeature) -> Unit, - private val onDeleteListener: (feature: DisplayFeature) -> Unit, - private val onAddListener: (feature: DisplayFeature) -> Unit, - private val onHistoryListener: (feature: DisplayFeature) -> Unit) { - fun onRename(feature: DisplayFeature) = onRenameListener(feature) - fun onDelete(feature: DisplayFeature) = onDeleteListener(feature) - fun onAdd(feature: DisplayFeature) = onAddListener(feature) - fun onHistory(feature: DisplayFeature) = onHistoryListener(feature) -} diff --git a/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/ImportFeaturesDialog.kt b/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/ImportFeaturesDialog.kt deleted file mode 100644 index 0239b69ab..000000000 --- a/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/ImportFeaturesDialog.kt +++ /dev/null @@ -1,191 +0,0 @@ -package com.samco.trackandgraph.displaytrackgroup - -import android.app.Activity -import android.app.Dialog -import android.content.DialogInterface -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.View -import android.widget.Button -import android.widget.ProgressBar -import android.widget.Toast -import androidx.appcompat.app.AlertDialog -import androidx.core.content.ContextCompat -import androidx.fragment.app.DialogFragment -import androidx.lifecycle.* -import androidx.room.withTransaction -import com.samco.trackandgraph.R -import com.samco.trackandgraph.database.TrackAndGraphDatabase -import com.samco.trackandgraph.util.CSVReadWriter -import com.samco.trackandgraph.util.ImportExportFeatureUtils -import kotlinx.coroutines.* - -const val OPEN_FILE_REQUEST_CODE = 124 - -enum class ImportState { WAITING, IMPORTING, DONE } -class ImportFeaturesDialog : DialogFragment() { - private var trackGroupName: String? = null - private var trackGroupId: Long? = null - - private lateinit var viewModel: ImportFeaturesViewModel - private lateinit var alertDialog: AlertDialog - private lateinit var fileButton: Button - private lateinit var progressBar: ProgressBar - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - return activity?.let { - viewModel = ViewModelProviders.of(this).get(ImportFeaturesViewModel::class.java) - val view = it.layoutInflater.inflate(R.layout.import_features_dialog, null) - trackGroupName = arguments!!.getString(TRACK_GROUP_NAME_KEY) - trackGroupId = arguments!!.getLong(TRACK_GROUP_ID_KEY) - - fileButton = view.findViewById(R.id.fileButton) - progressBar = view.findViewById(R.id.progressBar) - - progressBar.visibility = View.INVISIBLE - fileButton.setOnClickListener { onFileButtonClicked() } - fileButton.text = getString(R.string.select_file) - fileButton.setTextColor(ContextCompat.getColor(context!!, R.color.errorText)) - - val builder = AlertDialog.Builder(it) - builder.setView(view) - .setPositiveButton(R.string.importButton) { _, _ -> null } - .setNegativeButton(R.string.cancel) { _, _ -> null } - alertDialog = builder.create() - alertDialog.setCanceledOnTouchOutside(true) - alertDialog.setOnShowListener { setAlertDialogShowListeners() } - alertDialog - } ?: throw IllegalStateException("Activity cannot be null") - } - - private fun setAlertDialogShowListeners() { - val positiveButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE) - positiveButton.isEnabled = false - positiveButton.setOnClickListener { onImportClicked() } - listenToUri() - listenToImportState() - listenToException() - val negativeButton = alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE) - negativeButton.setOnClickListener { dismiss() } - alertDialog.setOnCancelListener { null } - } - - private fun listenToUri() { - viewModel.selectedFileUri.observe(this, Observer { uri -> - if (uri != null) { - ImportExportFeatureUtils.setFileButtonTextFromUri(activity, context!!, uri, fileButton, alertDialog) - } - }) - } - - private fun listenToImportState() { - viewModel.importState.observe(this, Observer{ state -> - when (state) { - ImportState.WAITING -> { - progressBar.visibility = View.INVISIBLE - } - ImportState.IMPORTING -> { - progressBar.visibility = View.VISIBLE - val positiveButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE) - positiveButton.isEnabled = false - fileButton.isEnabled = false - } - ImportState.DONE -> dismiss() - } - }) - } - - private fun listenToException() { - viewModel.importException.observe(this, Observer { exception -> - if (exception != null) { - val message = - if (exception.stringArgs == null) getString(exception.stringId) - else getString(exception.stringId, exception.stringArgs) - Toast.makeText(activity!!, message, Toast.LENGTH_LONG).show() - } - }) - } - - private fun onFileButtonClicked() { - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "text/csv" - } - startActivityForResult(intent, OPEN_FILE_REQUEST_CODE) - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) { - super.onActivityResult(requestCode, resultCode, resultData) - if (requestCode == OPEN_FILE_REQUEST_CODE) { - resultData?.data.also { uri -> - if (uri != null) { - viewModel.selectedFileUri.value = uri - } - } - } - } - - private fun onImportClicked() { - progressBar.visibility = View.VISIBLE - viewModel.beginImport(activity!!, trackGroupId!!) - } - - override fun onCancel(dialog: DialogInterface) { - super.onCancel(dialog) - if (viewModel.importState.value != ImportState.IMPORTING) dismiss() - } -} - -class ImportFeaturesViewModel : ViewModel() { - private var updateJob = Job() - private val uiScope = CoroutineScope(Dispatchers.Main + updateJob) - - val selectedFileUri: MutableLiveData by lazy { - val uri = MutableLiveData() - uri.value = null - return@lazy uri - } - - val importState: LiveData get() { return _importState } - private val _importState: MutableLiveData by lazy { - val state = MutableLiveData() - state.value = ImportState.WAITING - return@lazy state - } - - val importException: LiveData get() { return _importException } - private val _importException: MutableLiveData by lazy { - val exception = MutableLiveData() - exception.value = null - return@lazy exception - } - - fun beginImport(activity: Activity, trackGroupId: Long) { - if (_importState.value == ImportState.IMPORTING) return - selectedFileUri.value?.let { - _importState.value = ImportState.IMPORTING - uiScope.launch { - try { - withContext(Dispatchers.IO) { - val inputStream = activity.contentResolver.openInputStream(it) - if (inputStream != null) { - val application = activity.application - val database = TrackAndGraphDatabase.getInstance(application) - val dao = database.trackAndGraphDatabaseDao - database.withTransaction { - CSVReadWriter.readFeaturesFromCSV(dao, inputStream, trackGroupId) - } - } - } - } catch (e: CSVReadWriter.ImportFeaturesException) { _importException.value = e } - _importState.value = ImportState.DONE - } - } - } - - override fun onCleared() { - super.onCleared() - updateJob.cancel() - } -} diff --git a/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/InputDataPointDialog.kt b/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/InputDataPointDialog.kt deleted file mode 100644 index d071f3035..000000000 --- a/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/InputDataPointDialog.kt +++ /dev/null @@ -1,254 +0,0 @@ -package com.samco.trackandgraph.displaytrackgroup - -import android.app.Activity -import android.content.Context -import android.os.Bundle -import android.view.View -import androidx.fragment.app.DialogFragment -import org.threeten.bp.OffsetDateTime -import android.content.DialogInterface -import android.view.LayoutInflater -import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProviders -import androidx.viewpager.widget.PagerAdapter -import androidx.viewpager.widget.ViewPager -import com.samco.trackandgraph.database.* -import com.samco.trackandgraph.databinding.DataPointInputDialogBinding -import kotlinx.android.synthetic.main.data_point_input_dialog.* -import kotlinx.coroutines.* - -const val FEATURE_LIST_KEY = "FEATURE_LIST_KEY" -const val DATA_POINT_TIMESTAMP_KEY = "DATA_POINT_ID" - -class InputDataPointDialog : DialogFragment(), ViewPager.OnPageChangeListener { - private lateinit var viewModel: InputDataPointDialogViewModel - private val inputViews = mutableMapOf() - private lateinit var binding: DataPointInputDialogBinding - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return activity?.let { - viewModel = ViewModelProviders.of(this).get(InputDataPointDialogViewModel::class.java) - initViewModel() - binding = DataPointInputDialogBinding.inflate(inflater, container, false) - - binding.cancelButton.setOnClickListener { dismiss() } - binding.skipButton.setOnClickListener { skip() } - binding.addButton.setOnClickListener { onAddClicked() } - - listenToFeatures() - listenToIndex() - listenToState() - - binding.viewPager.addOnPageChangeListener(this) - dialog?.setCanceledOnTouchOutside(true) - - binding.root - } ?: throw IllegalStateException("Activity cannot be null") - } - - private fun listenToState() { - viewModel.state.observe(this, Observer { state -> - when (state) { - InputDataPointDialogState.LOADING -> { binding.addButton.isEnabled = false } - InputDataPointDialogState.WAITING -> { binding.addButton.isEnabled = true } - InputDataPointDialogState.ADDING -> { binding.addButton.isEnabled = false } - InputDataPointDialogState.ADDED -> { - binding.addButton.isEnabled = true - if (viewModel.currentFeatureIndex.value!! < viewModel.features.value!!.size-1) { - skip() - viewModel.onFinishedTransition() - } - else dismiss() - } - } - }) - } - - private fun listenToIndex() { - viewModel.currentFeatureIndex.observe(this, Observer { index -> - if (index != null) setupViewFeature(viewModel.features.value!![index], index) - }) - } - - private fun listenToFeatures() { - viewModel.features.observe(this, Observer { features -> - if (features.isEmpty()) return@Observer - binding.viewPager.adapter = ViewPagerAdapter( - context!!, - features, - DataPointInputView.DataPointInputClickListener(this::onAddClicked), - viewModel.uiStates, - inputViews - ) - if (features.size == 1) indexText.visibility = View.INVISIBLE - }) - } - - private fun initViewModel() { - val timestampStr = arguments!!.getString(DATA_POINT_TIMESTAMP_KEY) - val timestamp = if (timestampStr != null) odtFromString(timestampStr) else null - viewModel.loadData(activity!!, arguments!!.getLongArray(FEATURE_LIST_KEY)!!.toList(), timestamp) - } - - private class ViewPagerAdapter(val context: Context, - val features: List, - val clickListener: DataPointInputView.DataPointInputClickListener, - val uiStates: Map, - val inputViews: MutableMap) : PagerAdapter() { - override fun isViewFromObject(view: View, `object`: Any): Boolean { - return view == `object` - } - - override fun instantiateItem(container: ViewGroup, position: Int): Any { - val view = DataPointInputView(context, uiStates[features[position]]!!) - val params = ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) - view.layoutParams = params - view.setOnClickListener(clickListener) - inputViews[position] = view - container.addView(view) - return view - } - - override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) { - container.removeView(`object` as View) - } - - override fun getCount() = features.size - } - - override fun onPageScrollStateChanged(state: Int) { } - override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { } - override fun onPageSelected(position: Int) { viewModel.currentFeatureIndex.value = position } - - private fun setupViewFeature(feature: Feature, index: Int) { - if (feature.featureType == FeatureType.DISCRETE) binding.addButton.visibility = View.GONE - else binding.addButton.visibility = View.VISIBLE - if (index == viewModel.features.value!!.size-1) binding.skipButton.visibility = View.GONE - else binding.skipButton.visibility = View.VISIBLE - indexText.text = "${index+1} / ${viewModel.features.value!!.size}" - - //SHOW/HIDE KEYBOARD - val imm = activity?.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager - if (feature.featureType == FeatureType.CONTINUOUS) imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, InputMethodManager.HIDE_IMPLICIT_ONLY) - else imm.hideSoftInputFromWindow(view?.windowToken, 0) - activity!!.currentFocus?.clearFocus() - } - - - override fun onResume() { - super.onResume() - dialog?.window?.setLayout( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - } - - private fun onAddClicked() { - val currIndex = viewModel.currentFeatureIndex.value!! - val currFeature = viewModel.features.value!![currIndex] - onAddClicked(currFeature) - } - - private fun onAddClicked(feature: Feature) { - onSubmitResult(viewModel.uiStates[feature]!!) - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - activity!!.currentFocus?.clearFocus() - val imm = activity?.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(activity!!.window.decorView.windowToken, InputMethodManager.HIDE_NOT_ALWAYS) - } - - private fun onSubmitResult(dataPointInputData: DataPointInputView.DataPointInputData) { - val value = if (dataPointInputData.value.isEmpty()) "0" else dataPointInputData.value - viewModel.onDataPointInput(activity!!, - DataPoint(dataPointInputData.dateTime, dataPointInputData.feature.id, value, dataPointInputData.label) - ) - } - - private fun skip() = binding.viewPager.setCurrentItem(binding.viewPager.currentItem + 1, true) -} - -enum class InputDataPointDialogState { LOADING, WAITING, ADDING, ADDED } -class InputDataPointDialogViewModel : ViewModel() { - private var updateJob = Job() - private val uiScope = CoroutineScope(Dispatchers.Main + updateJob) - - val state: LiveData get() { return _state } - private val _state by lazy { - val state = MutableLiveData() - state.value = InputDataPointDialogState.LOADING - return@lazy state - } - - lateinit var uiStates: Map - val features: LiveData> get() { return _features } - private val _features by lazy { - val feats = MutableLiveData>() - feats.value = listOf() - return@lazy feats - } - - val currentFeatureIndex: MutableLiveData get() { return _currentFeatureIndex } - private val _currentFeatureIndex by lazy { - val index = MutableLiveData() - index.value = null - return@lazy index - } - - fun loadData(activity: Activity, featureIds: List, dataPointTimestamp: OffsetDateTime?) { - if (features.value!!.isNotEmpty()) return - val application = activity.application - val dao = TrackAndGraphDatabase.getInstance(application).trackAndGraphDatabaseDao - uiScope.launch { - _state.value = InputDataPointDialogState.LOADING - lateinit var featureData: List - var dataPointData: DataPoint? = null - withContext(Dispatchers.IO) { - featureData = dao.getFeaturesByIdsSync(featureIds) - if (dataPointTimestamp != null) { - dataPointData = dao.getDataPointByTimestampAndFeatureSync(featureData[0].id, dataPointTimestamp) - } - } - - val defaultValue = if (dataPointData != null) dataPointData!!.value else "" - val defaultLabel = if (dataPointData != null) dataPointData!!.label else "" - val timestamp = if (dataPointData != null) dataPointData!!.timestamp else OffsetDateTime.now() - val timeModifiable = dataPointData == null - uiStates = featureData.map { f -> - f to DataPointInputView.DataPointInputData(f, timestamp, defaultValue, defaultLabel, timeModifiable) - }.toMap() - _features.value = featureData - _currentFeatureIndex.value = 0 - _state.value = InputDataPointDialogState.WAITING - } - } - - fun onDataPointInput(activity: Activity, dataPoint: DataPoint) { - if (state.value != InputDataPointDialogState.WAITING) return - val application = activity.application - val dao = TrackAndGraphDatabase.getInstance(application).trackAndGraphDatabaseDao - uiScope.launch { - _state.value = InputDataPointDialogState.ADDING - withContext(Dispatchers.IO) { - dao.insertDataPoint(dataPoint) - } - _state.value = InputDataPointDialogState.ADDED - } - } - - fun onFinishedTransition() { - _state.value = InputDataPointDialogState.WAITING - } - - override fun onCleared() { - super.onCleared() - uiScope.cancel() - } -} diff --git a/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/RenameFeatureDialogFragment.kt b/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/RenameFeatureDialogFragment.kt deleted file mode 100644 index 9363db9a1..000000000 --- a/app/src/main/java/com/samco/trackandgraph/displaytrackgroup/RenameFeatureDialogFragment.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.samco.trackandgraph.displaytrackgroup - -import androidx.fragment.app.Fragment -import com.samco.trackandgraph.R -import com.samco.trackandgraph.database.Feature -import com.samco.trackandgraph.ui.NameInputDialogFragment - -class RenameFeatureDialogFragment : NameInputDialogFragment() { - - private lateinit var listener: RenameFeatureDialogListener - private lateinit var feature: Feature - - interface RenameFeatureDialogListener { - fun getFeature() : Feature - fun onRenameFeature(newName: String) - fun getMaxFeatureNameChars(): Int - } - - override fun getMaxChars(): Int = listener.getMaxFeatureNameChars() - - override fun registerListener(parentFragment: Fragment?) { - listener = parentFragment as RenameFeatureDialogListener - feature = listener.getFeature() - } - - override fun getPositiveButtonName(): String { - return getString(R.string.rename) - } - - override fun onPositiveClicked(name: String) = listener.onRenameFeature(name) - - override fun getNameInputHint(): String = "" - - override fun getTitleText(): String = getString(R.string.new_name) - - override fun getNameInputText(): String = feature.name -} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/faq/FAQRedirectFragment.kt b/app/src/main/java/com/samco/trackandgraph/faq/FAQRedirectFragment.kt new file mode 100644 index 000000000..f4c015d8b --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/faq/FAQRedirectFragment.kt @@ -0,0 +1,38 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.faq + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.navigation.findNavController +import com.samco.trackandgraph.R + +class FAQRedirectFragment : Fragment() { + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val navController = container?.findNavController() + val url = getString(R.string.faq_page_link) + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + startActivity(browserIntent) + navController?.popBackStack() + return super.onCreateView(inflater, container, savedInstanceState) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/featurehistory/DataPointAdapter.kt b/app/src/main/java/com/samco/trackandgraph/featurehistory/DataPointAdapter.kt deleted file mode 100644 index a9f9f7c06..000000000 --- a/app/src/main/java/com/samco/trackandgraph/featurehistory/DataPointAdapter.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.samco.trackandgraph.featurehistory - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.samco.trackandgraph.database.DataPoint -import com.samco.trackandgraph.databinding.ListItemDataPointBinding - -class DataPointAdapter(private val clickListener: DataPointClickListener) - : ListAdapter(DataPointDiffCallback()) { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - return ViewHolder.from(parent, clickListener) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.bind(getItem(position)) - } - - class ViewHolder private constructor(private val binding: ListItemDataPointBinding, - private val clickListener: DataPointClickListener) - : RecyclerView.ViewHolder(binding.root) { - - fun bind(dataPoint: DataPoint) { - binding.dataPoint = dataPoint - binding.editButton.setOnClickListener { clickListener.editClicked(dataPoint) } - binding.deleteButton.setOnClickListener { clickListener.deleteClicked(dataPoint) } - } - - companion object { - fun from(parent: ViewGroup, clickListener: DataPointClickListener): ViewHolder { - val layoutInflater = LayoutInflater.from(parent.context) - val binding = ListItemDataPointBinding.inflate(layoutInflater, parent, false) - return ViewHolder(binding, clickListener) - } - } - } -} -class DataPointDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: DataPoint, newItem: DataPoint) = oldItem.timestamp == newItem.timestamp && oldItem.featureId == newItem.featureId - override fun areContentsTheSame(oldItem: DataPoint, newItem: DataPoint) = oldItem == newItem -} -class DataPointClickListener(private val onEditDataPoint: (DataPoint) -> Unit, - private val onDeleteDataPoint: (DataPoint) -> Unit) { - fun editClicked(dataPoint: DataPoint) = onEditDataPoint(dataPoint) - fun deleteClicked(dataPoint: DataPoint) = onDeleteDataPoint(dataPoint) -} diff --git a/app/src/main/java/com/samco/trackandgraph/featurehistory/FeatureHistoryView.kt b/app/src/main/java/com/samco/trackandgraph/featurehistory/FeatureHistoryView.kt new file mode 100644 index 000000000..b2a6e84a0 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/featurehistory/FeatureHistoryView.kt @@ -0,0 +1,318 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.featurehistory + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.hilt.navigation.compose.hiltViewModel +import com.samco.trackandgraph.R +import com.samco.trackandgraph.adddatapoint.AddDataPointsDialog +import com.samco.trackandgraph.adddatapoint.AddDataPointsNavigationViewModel +import com.samco.trackandgraph.adddatapoint.AddDataPointsViewModelImpl +import com.samco.trackandgraph.base.database.dto.Tracker +import com.samco.trackandgraph.base.helpers.formatDayMonthYearHourMinuteWeekDayTwoLines +import com.samco.trackandgraph.base.helpers.getWeekDayNames +import com.samco.trackandgraph.ui.compose.theming.tngColors +import com.samco.trackandgraph.ui.compose.ui.* + +@Composable +fun FeatureHistoryView(viewModel: FeatureHistoryViewModel) { + val dateScrollData = viewModel.dateScrollData.observeAsState().value + val weekdayNames = getWeekDayNames(LocalContext.current) + val isDuration by viewModel.isDuration.observeAsState(false) + val tracker by viewModel.tracker.observeAsState(null) + val featureInfo by viewModel.showFeatureInfo.observeAsState() + val dataPointInfo by viewModel.showDataPointInfo.observeAsState() + val dataPointDialogViewModel = hiltViewModel() + + if (dateScrollData == null || dateScrollData.items.isEmpty()) { + EmptyScreenText(textId = R.string.no_data_points_history_fragment_hint) + } else { + DateScrollLazyColumn( + modifier = Modifier.padding(dimensionResource(id = R.dimen.card_margin_small)), + data = dateScrollData + ) { + DataPoint( + dataPoint = it, + addDataPointsViewModel = dataPointDialogViewModel, + viewModel = viewModel, + weekdayNames = weekdayNames, + isDuration = isDuration, + tracker = tracker + ) + Spacer(modifier = Modifier.height(dimensionResource(id = R.dimen.card_margin_small))) + } + } + + featureInfo?.let { + FeatureInfoDialog( + feature = it, + onDismissRequest = viewModel::onHideFeatureInfo + ) + } + + dataPointInfo?.let { + DataPointInfoDialog( + dataPoint = it.toDataPoint(), + weekdayNames = weekdayNames, + isDuration = isDuration, + onDismissRequest = viewModel::onDismissDataPoint + ) + } + + if (viewModel.showDeleteConfirmDialog.observeAsState(false).value) { + ConfirmCancelDialog( + body = R.string.ru_sure_del_data_point, + onDismissRequest = viewModel::onDeleteDismissed, + onConfirm = viewModel::onDeleteConfirmed + ) + } + + if (viewModel.showUpdateDialog.observeAsState(false).value) { + UpdateDialog(viewModel = viewModel) + } + + if (viewModel.showUpdateWarning.observeAsState(false).value) { + UpdateWarningDialog( + viewModel::onCancelUpdateWarning, + viewModel::onConfirmUpdateWarning + ) + } + + if (viewModel.isUpdating.observeAsState(false).value) { + LoadingOverlay() + } + + if (!dataPointDialogViewModel.hidden.observeAsState(true).value) { + AddDataPointsDialog( + viewModel = dataPointDialogViewModel, + onDismissRequest = { dataPointDialogViewModel.reset() } + ) + } +} + +@Composable +private fun UpdateWarningDialog( + onDismissRequest: () -> Unit, + onConfirm: () -> Unit +) = ConfirmCancelDialog( + body = R.string.ru_sure_update_data, + onDismissRequest = onDismissRequest, + onConfirm = onConfirm +) + + +@Composable +private fun UpdateDialog( + viewModel: UpdateDialogViewModel +) = CustomConfirmCancelDialog( + onDismissRequest = viewModel::onCancelUpdate, + customWidthPercentage = 0.9f, + onConfirm = viewModel::onUpdateClicked, + continueText = R.string.update, + continueEnabled = viewModel.updateButtonEnabled.observeAsState(false).value +) { + + Text( + stringResource(R.string.update_all_data_points), + fontSize = MaterialTheme.typography.h4.fontSize, + fontWeight = MaterialTheme.typography.h4.fontWeight, + ) + InputSpacingLarge() + + Text( + stringResource(R.string.where_colon), + fontSize = MaterialTheme.typography.subtitle1.fontSize, + fontWeight = MaterialTheme.typography.subtitle1.fontWeight, + ) + DialogInputSpacing() + WhereValueInput(viewModel) + DialogInputSpacing() + WhereLabelInput(viewModel) + + InputSpacingLarge() + + Text( + stringResource(R.string.to_colon), + fontSize = MaterialTheme.typography.subtitle1.fontSize, + fontWeight = MaterialTheme.typography.subtitle1.fontWeight, + ) + + DialogInputSpacing() + ToValueInput(viewModel) + DialogInputSpacing() + ToLabelInput(viewModel) +} + +@Composable +private fun ToLabelInput(viewModel: UpdateDialogViewModel) { + val focusRequester = remember { FocusRequester() } + + CheckboxLabeledExpandingSection( + checked = viewModel.toLabelEnabled.observeAsState(false).value, + onCheckedChanged = viewModel::setToLabelEnabled, + label = stringResource(R.string.label_equals), + focusRequester = focusRequester + ) { + LabelInputTextField( + modifier = it, + textFieldValue = viewModel.toLabel, + onValueChange = viewModel::setToTextLabel + ) + } +} + +@Composable +private fun ToValueInput(viewModel: UpdateDialogViewModel) { + val focusRequester = remember { FocusRequester() } + val isDuration by viewModel.isDuration.observeAsState(false) + + CheckboxLabeledExpandingSection( + checked = viewModel.toValueEnabled.observeAsState(false).value, + onCheckedChanged = viewModel::setToValueEnabled, + label = stringResource(R.string.value_equals), + focusRequester = focusRequester + ) { + if (isDuration) { + DurationInput( + modifier = it, + viewModel = viewModel.toDurationViewModel + ) + } else { + ValueInputTextField( + modifier = it, + textFieldValue = viewModel.toValue, + onValueChange = viewModel::setToTextValue + ) + } + } +} + +@Composable +private fun WhereLabelInput(viewModel: UpdateDialogViewModel) { + val focusRequester = remember { FocusRequester() } + + CheckboxLabeledExpandingSection( + checked = viewModel.whereLabelEnabled.observeAsState(false).value, + onCheckedChanged = viewModel::setWhereLabelEnabled, + label = stringResource(R.string.label_equals), + focusRequester = focusRequester + ) { + LabelInputTextField( + modifier = it, + textFieldValue = viewModel.whereLabel, + onValueChange = viewModel::setWhereTextLabel, + ) + } +} + +@Composable +private fun WhereValueInput( + viewModel: UpdateDialogViewModel +) { + val focusRequester = remember { FocusRequester() } + val isDuration by viewModel.isDuration.observeAsState(false) + + CheckboxLabeledExpandingSection( + checked = viewModel.whereValueEnabled.observeAsState(false).value, + onCheckedChanged = viewModel::setWhereValueEnabled, + label = stringResource(R.string.value_equals), + focusRequester = focusRequester + ) { + if (isDuration) { + DurationInput( + modifier = it, + viewModel = viewModel.whereDurationViewModel + ) + } else { + ValueInputTextField( + modifier = it, + textFieldValue = viewModel.whereValue, + onValueChange = viewModel::setWhereTextValue, + ) + } + } +} + + +@Composable +private fun DataPoint( + dataPoint: DataPointInfo, + addDataPointsViewModel: AddDataPointsNavigationViewModel, + viewModel: FeatureHistoryViewModel, + weekdayNames: List, + isDuration: Boolean, + tracker: Tracker? +) = Card( + modifier = Modifier + .clickable { viewModel.onDataPointClicked(dataPoint) }, + elevation = dimensionResource(id = R.dimen.card_elevation) +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(dimensionResource(id = R.dimen.card_margin_small)) + ) { + Text( + text = formatDayMonthYearHourMinuteWeekDayTwoLines( + LocalContext.current, + weekdayNames, + dataPoint.date + ), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.body2, + ) + DialogInputSpacing() + DataPointValueAndDescription( + modifier = Modifier.weight(1f), + dataPoint = dataPoint.toDataPoint(), + isDuration = isDuration + ) + if (tracker != null) { + IconButton(onClick = { + addDataPointsViewModel.showAddDataPointDialog( + trackerId = tracker.id, + dataPointTimestamp = dataPoint.date + ) + }) { + Icon( + painter = painterResource(id = R.drawable.edit_icon), + contentDescription = stringResource(id = R.string.edit_data_point_button_content_description), + tint = MaterialTheme.tngColors.secondary + ) + } + IconButton(onClick = { viewModel.onDeleteClicked(dataPoint) }) { + Icon( + painter = painterResource(id = R.drawable.delete_icon), + contentDescription = stringResource(id = R.string.delete_data_point_button_content_description), + tint = MaterialTheme.tngColors.primary + ) + } + } + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/featurehistory/FeatureHistoryViewModel.kt b/app/src/main/java/com/samco/trackandgraph/featurehistory/FeatureHistoryViewModel.kt index fa865b5cd..4170edcba 100644 --- a/app/src/main/java/com/samco/trackandgraph/featurehistory/FeatureHistoryViewModel.kt +++ b/app/src/main/java/com/samco/trackandgraph/featurehistory/FeatureHistoryViewModel.kt @@ -1,12 +1,242 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +@file:OptIn(FlowPreview::class) + package com.samco.trackandgraph.featurehistory -import androidx.lifecycle.ViewModel -import com.samco.trackandgraph.database.DataPoint -import com.samco.trackandgraph.database.Feature -import com.samco.trackandgraph.database.TrackAndGraphDatabaseDao +import androidx.lifecycle.* +import com.samco.trackandgraph.base.database.dto.DataPoint +import com.samco.trackandgraph.base.database.dto.Feature +import com.samco.trackandgraph.base.database.dto.Tracker +import com.samco.trackandgraph.base.database.sampling.DataSampleProperties +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.base.model.di.MainDispatcher +import com.samco.trackandgraph.ui.compose.ui.Datable +import com.samco.trackandgraph.ui.compose.ui.DateDisplayResolution +import com.samco.trackandgraph.ui.compose.ui.DateScrollData +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import org.threeten.bp.Duration +import org.threeten.bp.OffsetDateTime +import javax.inject.Inject + +data class DataPointInfo( + override val date: OffsetDateTime, + val featureId: Long, + val value: Double, + val label: String, + val note: String, +) : Datable { + fun toDataPoint() = DataPoint( + timestamp = date, + featureId = featureId, + value = value, + label = label, + note = note + ) +} + +interface FeatureHistoryNavigationViewModel { + fun initViewModel(featureId: Long) + + fun showUpdateAllDialog() +} + +//TODO we should probably add a loading state for this when we're first loading the data +interface FeatureHistoryViewModel : UpdateDialogViewModel { + val tracker: LiveData + val dateScrollData: LiveData> + val showFeatureInfo: LiveData + val showDataPointInfo: LiveData + val showDeleteConfirmDialog: LiveData + val showUpdateDialog: LiveData + + fun onDeleteClicked(dataPoint: DataPointInfo) + fun onDeleteConfirmed() + fun onDeleteDismissed() + fun onDataPointClicked(dataPoint: DataPointInfo) + fun onDismissDataPoint() + fun onShowFeatureInfo() + fun onHideFeatureInfo() +} + +@HiltViewModel +class FeatureHistoryViewModelImpl @Inject constructor( + private val dataInteractor: DataInteractor, + @IODispatcher private val io: CoroutineDispatcher, + @MainDispatcher private val ui: CoroutineDispatcher +) : UpdateDialogViewModelImpl(), + FeatureHistoryViewModel, + FeatureHistoryNavigationViewModel { + private val featureIdFlow = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + + private data class RawData( + val dataSampleProperties: DataSampleProperties, + val dataPoints: List + ) + + private val dataUpdates = dataInteractor + .getDataUpdateEvents() + .map { } + .debounce(10) + .onStart { emit(Unit) } + + private val dataSample = + combine(featureIdFlow, dataUpdates) { id, _ -> id } + .map { featureId -> + val dataSample = dataInteractor.getDataSampleForFeatureId(featureId) + val answer = RawData( + dataSample.dataSampleProperties, + dataSample.getAllRawDataPoints() + .sortedByDescending { it.timestamp.toEpochSecond() } + .map { it.toDataPointInfo() } + ) + dataSample.dispose() + return@map answer + } + .flowOn(io) + + override val isDuration: LiveData = dataSample.map { + it.dataSampleProperties.isDuration + }.asLiveData(viewModelScope.coroutineContext) + + override val dateScrollData: LiveData> = dataSample + .filter { it.dataPoints.isNotEmpty() } + .map { it.dataPoints } + .map { dataPoints -> + val range = Duration + .between(dataPoints.last().date, dataPoints.first().date) + .abs() + + val dateDisplayResolution = when { + range.toDays() > 365 -> DateDisplayResolution.MONTH_YEAR + else -> DateDisplayResolution.MONTH_DAY + } + + DateScrollData( + dateDisplayResolution = dateDisplayResolution, + items = dataPoints + ) + }.asLiveData(viewModelScope.coroutineContext) + + private val showFeatureInfoFlow = MutableStateFlow(false) + + override val showFeatureInfo: LiveData = combine( + showFeatureInfoFlow, + featureIdFlow.map { dataInteractor.getFeatureById(it) }) { show, feature -> + if (show) feature else null + }.asLiveData(viewModelScope.coroutineContext) + + override val showDataPointInfo = MutableLiveData(null) + + private val confirmDeleteDataPoint = MutableStateFlow(null) + + override val showDeleteConfirmDialog = confirmDeleteDataPoint + .map { it != null } + .asLiveData(viewModelScope.coroutineContext) + + override val showUpdateDialog = MutableLiveData(false) + + private val trackerFlow: StateFlow = featureIdFlow + .map { dataInteractor.getTrackerByFeatureId(it) } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + override val tracker: LiveData = trackerFlow + .asLiveData(viewModelScope.coroutineContext) + + private var featureId: Long? = null + + override fun initViewModel(featureId: Long) { + if (this.featureId != null) return + this.featureId = featureId + viewModelScope.launch(io) { featureIdFlow.emit(featureId) } + } + + override fun onDeleteClicked(dataPoint: DataPointInfo) { + confirmDeleteDataPoint.value = dataPoint + } + + override fun onDeleteConfirmed() { + viewModelScope.launch(io) { + confirmDeleteDataPoint.value?.let { + dataInteractor.deleteDataPoint(it.toDataPoint()) + } + confirmDeleteDataPoint.value = null + } + } + + override fun onDeleteDismissed() { + confirmDeleteDataPoint.value = null + } + + override fun onDataPointClicked(dataPoint: DataPointInfo) { + showFeatureInfoFlow.value = false + showDataPointInfo.value = dataPoint + } + + override fun onDismissDataPoint() { + showDataPointInfo.value = null + } + + override fun onShowFeatureInfo() { + showDataPointInfo.value = null + showFeatureInfoFlow.value = true + } + + override fun onHideFeatureInfo() { + showFeatureInfoFlow.value = false + } + + override val isUpdating = MutableLiveData(false) + + override fun showUpdateAllDialog() { + showUpdateDialog.value = true + } + + override fun onConfirmUpdateWarning() { + showUpdateWarning.value = false + showUpdateDialog.value = false + viewModelScope.launch(io) { + withContext(ui) { isUpdating.value = true } + trackerFlow.value?.let { + dataInteractor.updateDataPoints( + trackerId = it.id, + whereValue = getWhereValueDouble(), + whereLabel = getWhereLabelString(), + toValue = getToValueDouble(), + toLabel = getToLabelString() + ) + } + withContext(ui) { isUpdating.value = false } + } + } + + override fun onCancelUpdate() { + showUpdateDialog.value = false + showUpdateWarning.value = false + } -class FeatureHistoryViewModel(featureId: Long, dataSource: TrackAndGraphDatabaseDao) : ViewModel() { - var feature: Feature? = null - val dataPoints = dataSource.getDataPointsForFeature(featureId) - var currentActionDataPoint: DataPoint? = null + private fun DataPoint.toDataPointInfo() = DataPointInfo( + date = timestamp, + featureId = featureId, + value = value, + label = label, + note = note, + ) } \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/featurehistory/FeatureHistoryViewModelFactory.kt b/app/src/main/java/com/samco/trackandgraph/featurehistory/FeatureHistoryViewModelFactory.kt deleted file mode 100644 index e4cfa193b..000000000 --- a/app/src/main/java/com/samco/trackandgraph/featurehistory/FeatureHistoryViewModelFactory.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.samco.trackandgraph.featurehistory - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.samco.trackandgraph.database.TrackAndGraphDatabaseDao - -class FeatureHistoryViewModelFactory( - private val featureId: Long, - private val dataSource: TrackAndGraphDatabaseDao -) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(FeatureHistoryViewModel::class.java)) { - return FeatureHistoryViewModel(featureId, dataSource) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/featurehistory/FragmentFeatureHistory.kt b/app/src/main/java/com/samco/trackandgraph/featurehistory/FragmentFeatureHistory.kt index 0d510a386..3380c23fe 100644 --- a/app/src/main/java/com/samco/trackandgraph/featurehistory/FragmentFeatureHistory.kt +++ b/app/src/main/java/com/samco/trackandgraph/featurehistory/FragmentFeatureHistory.kt @@ -1,118 +1,109 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ package com.samco.trackandgraph.featurehistory import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity -import androidx.databinding.DataBindingUtil +import android.view.* +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.ComposeView +import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment -import androidx.lifecycle.Observer -import androidx.lifecycle.ViewModelProviders +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.map import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView +import com.samco.trackandgraph.MainActivity +import com.samco.trackandgraph.NavButtonStyle import com.samco.trackandgraph.R -import com.samco.trackandgraph.database.DataPoint -import com.samco.trackandgraph.database.TrackAndGraphDatabase -import com.samco.trackandgraph.database.stringFromOdt -import com.samco.trackandgraph.databinding.FragmentFeatureHistoryBinding -import com.samco.trackandgraph.displaytrackgroup.DATA_POINT_TIMESTAMP_KEY -import com.samco.trackandgraph.displaytrackgroup.FEATURE_LIST_KEY -import com.samco.trackandgraph.displaytrackgroup.InputDataPointDialog -import com.samco.trackandgraph.ui.YesCancelDialogFragment -import kotlinx.coroutines.* +import com.samco.trackandgraph.settings.TngSettings +import com.samco.trackandgraph.ui.compose.compositionlocals.LocalSettings +import com.samco.trackandgraph.ui.compose.theming.TnGComposeTheme +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject - -class FragmentFeatureHistory : Fragment(), YesCancelDialogFragment.YesCancelDialogListener { - - private lateinit var binding: FragmentFeatureHistoryBinding - private lateinit var viewModel: FeatureHistoryViewModel +@AndroidEntryPoint +class FragmentFeatureHistory : Fragment() { + private val viewModel by viewModels() private val args: FragmentFeatureHistoryArgs by navArgs() - private var updateJob = Job() - private val uiScope = CoroutineScope(Dispatchers.Main + updateJob) + @Inject + lateinit var tngSettings: TngSettings - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - binding = DataBindingUtil.inflate(inflater, R.layout.fragment_feature_history, container, false) - binding.lifecycleOwner = viewLifecycleOwner - initFeature() - viewModel = createViewModel() - val adapter = DataPointAdapter(DataPointClickListener( - this::onEditDataPointClicked, - this::onDeleteDataPointClicked - )) - observeFeatureDataAndUpdate(viewModel, adapter) - binding.dataPointList.adapter = adapter - binding.dataPointList.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + viewModel.initViewModel(args.featureId) + viewModel.tracker.map { it != null }.observe(viewLifecycleOwner) { initMenuProvider(it) } + viewModel.dateScrollData.map { it.items.size }.observe(viewLifecycleOwner) { updateDataPointsCount(it) } - (activity as AppCompatActivity).supportActionBar?.title = args.featureName - return binding.root - } - - private fun initFeature() { - val application = requireActivity().application - val dao = TrackAndGraphDatabase.getInstance(application).trackAndGraphDatabaseDao - uiScope.launch { - withContext(Dispatchers.IO) { - viewModel.feature = dao.getFeatureById(args.feature) + return ComposeView(requireContext()).apply { + setContent { + CompositionLocalProvider(LocalSettings provides tngSettings) { + TnGComposeTheme { + FeatureHistoryView(viewModel = viewModel) + } + } } } } - private fun createViewModel(): FeatureHistoryViewModel { - val application = requireNotNull(this.activity).application - val dataSource = TrackAndGraphDatabase.getInstance(application).trackAndGraphDatabaseDao - val viewModelFactory = FeatureHistoryViewModelFactory(args.feature, dataSource) - return ViewModelProviders.of(this, viewModelFactory).get(FeatureHistoryViewModel::class.java) - } + private var menuProvider: MenuProvider? = null - private fun observeFeatureDataAndUpdate(featureHistoryViewModel: FeatureHistoryViewModel, adapter: DataPointAdapter) { - featureHistoryViewModel.dataPoints.observe(viewLifecycleOwner, Observer { - it?.let { - adapter.submitList(it) - } - }) + private fun initMenuProvider(isTracker: Boolean) { + menuProvider?.let { requireActivity().removeMenuProvider(it) } + FeatureHistoryMenuProvider(isTracker).let { + menuProvider = it + requireActivity().addMenuProvider(it, viewLifecycleOwner, Lifecycle.State.RESUMED) + } } - private fun onEditDataPointClicked(dataPoint: DataPoint) { - viewModel.feature?.let { - val dialog = InputDataPointDialog() - val argBundle = Bundle() - argBundle.putLongArray(FEATURE_LIST_KEY, longArrayOf(args.feature)) - argBundle.putString(DATA_POINT_TIMESTAMP_KEY, stringFromOdt(dataPoint.timestamp)) - dialog.arguments = argBundle - childFragmentManager.let { dialog.show(it, "input_data_point_dialog") } + private fun updateDataPointsCount(numDataPoints: Int) { + val text = if (numDataPoints > 0) { + context?.getString(R.string.data_points, numDataPoints) + } else { + null } - } - private fun onDeleteDataPointClicked(dataPoint: DataPoint) { - viewModel.currentActionDataPoint = dataPoint - val dialog = YesCancelDialogFragment() - var args = Bundle() - args.putString("title", getString(R.string.ru_sure_del_data_point)) - dialog.arguments = args - childFragmentManager.let { dialog.show(it, "ru_sure_del_data_point_fragment") } + (requireActivity() as MainActivity).setActionBarSubtitle(text) } - override fun onDialogYes(dialog: YesCancelDialogFragment) { - when (dialog.title) { - getString(R.string.ru_sure_del_data_point) -> deleteDataPoint(viewModel.currentActionDataPoint!!) + private inner class FeatureHistoryMenuProvider( + private val isTracker: Boolean + ) : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.feature_history_menu, menu) + if (!isTracker) menu.removeItem(R.id.updateButton) } - } - private fun deleteDataPoint(dataPoint: DataPoint) { - val application = requireActivity().application - val dao = TrackAndGraphDatabase.getInstance(application).trackAndGraphDatabaseDao - uiScope.launch { - withContext(Dispatchers.IO) { - dao.deleteDataPoint(dataPoint) + override fun onMenuItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.infoButton -> viewModel.onShowFeatureInfo() + R.id.updateButton -> viewModel.showUpdateAllDialog() + else -> return false } + return true } } - override fun onDestroy() { - super.onDestroy() - updateJob.cancel() + override fun onResume() { + super.onResume() + (requireActivity() as MainActivity).setActionBarConfig(NavButtonStyle.UP, args.featureName, true) } } + diff --git a/app/src/main/java/com/samco/trackandgraph/featurehistory/UpdateDialogViewModel.kt b/app/src/main/java/com/samco/trackandgraph/featurehistory/UpdateDialogViewModel.kt new file mode 100644 index 000000000..7059adb89 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/featurehistory/UpdateDialogViewModel.kt @@ -0,0 +1,138 @@ +package com.samco.trackandgraph.featurehistory + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.* +import com.samco.trackandgraph.ui.viewmodels.DurationInputViewModel +import com.samco.trackandgraph.ui.viewmodels.DurationInputViewModelImpl +import com.samco.trackandgraph.ui.viewmodels.asValidatedDouble +import kotlinx.coroutines.flow.combine + +interface UpdateDialogViewModel { + val isUpdating: LiveData + + val isDuration: LiveData + + val whereValueEnabled: LiveData + val whereLabelEnabled: LiveData + val whereValue: TextFieldValue + val whereDurationViewModel: DurationInputViewModel + val whereLabel: TextFieldValue + + val toValueEnabled: LiveData + val toLabelEnabled: LiveData + val toValue: TextFieldValue + val toDurationViewModel: DurationInputViewModel + val toLabel: TextFieldValue + + fun setWhereTextValue(value: TextFieldValue) + fun setToTextValue(value: TextFieldValue) + fun setWhereTextLabel(label: TextFieldValue) + fun setToTextLabel(label: TextFieldValue) + fun setWhereValueEnabled(enabled: Boolean) + fun setWhereLabelEnabled(enabled: Boolean) + fun setToValueEnabled(enabled: Boolean) + fun setToLabelEnabled(enabled: Boolean) + + val showUpdateWarning: LiveData + + val updateButtonEnabled: LiveData + + fun onUpdateClicked() + fun onCancelUpdate() + fun onConfirmUpdateWarning() + fun onCancelUpdateWarning() +} + +abstract class UpdateDialogViewModelImpl: ViewModel(), UpdateDialogViewModel { + + override val whereValueEnabled = MutableLiveData(false) + override val whereLabelEnabled = MutableLiveData(false) + override val toValueEnabled = MutableLiveData(false) + override val toLabelEnabled = MutableLiveData(false) + override var whereValue by mutableStateOf(TextFieldValue("1.0", TextRange(3))) + override var whereLabel by mutableStateOf(TextFieldValue("", TextRange(0))) + override var toValue by mutableStateOf(TextFieldValue("1.0", TextRange(3))) + override var toLabel by mutableStateOf(TextFieldValue("", TextRange(0))) + + override val whereDurationViewModel = DurationInputViewModelImpl() + override val toDurationViewModel = DurationInputViewModelImpl() + + override fun setWhereTextValue(value: TextFieldValue) { + whereValue = value.copy(text = value.text.asValidatedDouble()) + } + + override fun setToTextValue(value: TextFieldValue) { + toValue = value.copy(text = value.text.asValidatedDouble()) + } + + override fun setWhereTextLabel(label: TextFieldValue) { + whereLabel = label + } + + override fun setToTextLabel(label: TextFieldValue) { + toLabel = label + } + + override fun setWhereValueEnabled(enabled: Boolean) { + whereValueEnabled.value = enabled + } + + override fun setWhereLabelEnabled(enabled: Boolean) { + whereLabelEnabled.value = enabled + } + + override fun setToValueEnabled(enabled: Boolean) { + toValueEnabled.value = enabled + } + + override fun setToLabelEnabled(enabled: Boolean) { + toLabelEnabled.value = enabled + } + + override val showUpdateWarning = MutableLiveData(false) + + override val updateButtonEnabled by lazy { + combine( + whereValueEnabled.asFlow(), + whereLabelEnabled.asFlow(), + toValueEnabled.asFlow(), + toLabelEnabled.asFlow() + ) { whereVal, whereLabel, toVal, toLabel -> + (whereVal || whereLabel) && (toVal || toLabel) + }.asLiveData(viewModelScope.coroutineContext) + } + + override fun onUpdateClicked() { + showUpdateWarning.value = true + } + + override fun onCancelUpdateWarning() { + showUpdateWarning.value = false + } + + protected fun getToLabelString() = when { + toLabelEnabled.value != true -> null + else -> toLabel.text + } + + protected fun getToValueDouble() = when { + toValueEnabled.value != true -> null + isDuration.value == true -> toDurationViewModel.getDurationAsDouble() + else -> toValue.text.toDoubleOrNull() + } + + protected fun getWhereLabelString() = when { + whereLabelEnabled.value != true -> null + else -> whereLabel.text + } + + protected fun getWhereValueDouble() = when { + whereValueEnabled.value != true -> null + isDuration.value == true -> whereDurationViewModel.getDurationAsDouble() + else -> whereValue.text.toDoubleOrNull() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/functions/AddFunctionFragment.kt b/app/src/main/java/com/samco/trackandgraph/functions/AddFunctionFragment.kt new file mode 100644 index 000000000..608d05df1 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/functions/AddFunctionFragment.kt @@ -0,0 +1,107 @@ +package com.samco.trackandgraph.functions + +import android.os.Bundle +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.viewModels +import androidx.navigation.NavController +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import com.samco.trackandgraph.MainActivity +import com.samco.trackandgraph.NavButtonStyle +import com.samco.trackandgraph.R +import com.samco.trackandgraph.databinding.FragmentAddFunctionBinding +import com.samco.trackandgraph.util.bindingForViewLifecycle +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class AddFunctionFragment : Fragment() { + + private var binding: FragmentAddFunctionBinding by bindingForViewLifecycle() + private val args: AddFunctionFragmentArgs by navArgs() + private var navController: NavController? = null + private val viewModel: AddFunctionViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.initViewModel(args.groupId, args.functionId) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentAddFunctionBinding.inflate(inflater, container, false) + navController = container?.findNavController() + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + bindFunctionName() + bindFunctionDescription() + bindFunctionBody() + bindFinishButton() + bindLoadingOverlay() + } + + private fun bindLoadingOverlay() { + viewModel.isLoading.observe(viewLifecycleOwner) { + binding.loadingOverlay.visibility = if (it) View.VISIBLE else View.GONE + } + } + + private fun bindFinishButton() { + viewModel.isUpdateMode.observe(viewLifecycleOwner) { + binding.addBar.addButton.text = + if (it) requireContext().getString(R.string.update) + else requireContext().getString(R.string.finish) + } + viewModel.createUpdateButtonEnabled.observe(viewLifecycleOwner) { + binding.addBar.addButton.isEnabled = it + } + binding.addBar.addButton.setOnClickListener { + viewModel.onCreateOrUpdateClicked() + } + viewModel.finished.observe(viewLifecycleOwner) { + if (it) navController?.popBackStack() + } + } + + private fun bindFunctionBody() { + viewModel.functionScript.observe(viewLifecycleOwner) { + if (binding.functionBodyText.text.toString() != it) + binding.functionBodyText.setText(it) + } + binding.functionBodyText.addTextChangedListener { viewModel.setFunctionScript(it.toString()) } + } + + private fun bindFunctionDescription() { + viewModel.functionDescription.observe(viewLifecycleOwner) { + if (binding.functionDescriptionText.text.toString() != it) + binding.functionDescriptionText.setText(it) + } + binding.functionDescriptionText.addTextChangedListener { viewModel.setFunctionDescription(it.toString()) } + } + + private fun bindFunctionName() { + viewModel.functionName.observe(viewLifecycleOwner) { + if (binding.functionNameText.text.toString() != it) + binding.functionNameText.setText(it) + } + binding.functionNameText.addTextChangedListener { viewModel.setFunctionName(it.toString()) } + } + + override fun onResume() { + super.onResume() + val activity = (requireActivity() as MainActivity) + activity.setActionBarConfig( + NavButtonStyle.UP, + getString(R.string.add_function) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/functions/AddFunctionViewModel.kt b/app/src/main/java/com/samco/trackandgraph/functions/AddFunctionViewModel.kt new file mode 100644 index 000000000..145945a96 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/functions/AddFunctionViewModel.kt @@ -0,0 +1,125 @@ +package com.samco.trackandgraph.functions + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.samco.trackandgraph.base.database.dto.FunctionDto +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.base.model.di.MainDispatcher +import com.samco.trackandgraph.util.FeaturePathProvider +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +interface AddFunctionDataSourceViewModel { + val featurePathProvider: FeaturePathProvider +} + +interface AddFunctionViewModel { + fun initViewModel(groupId: Long, functionId: Long) + + val functionName: LiveData + val functionDescription: LiveData + val functionScript: LiveData + val isLoading: LiveData + val finished: LiveData + val isUpdateMode: LiveData + val createUpdateButtonEnabled: LiveData + //TODO we need an error text + + fun setFunctionName(name: String) + fun setFunctionDescription(description: String) + fun setFunctionScript(body: String) + fun onCreateOrUpdateClicked() +} + +@HiltViewModel +class AddFunctionViewModelImpl @Inject constructor( + private val dataInteractor: DataInteractor, + @IODispatcher private val io: CoroutineDispatcher, + @MainDispatcher private val ui: CoroutineDispatcher +) : ViewModel(), AddFunctionViewModel { + + override val functionName = MutableLiveData("") + override val functionDescription = MutableLiveData("") + override val functionScript = MutableLiveData("") + override val isLoading = MutableLiveData(false) + override val finished = MutableLiveData(false) + override val isUpdateMode = MutableLiveData(false) + + //TODO validate when update button should be enabled + override val createUpdateButtonEnabled = MutableLiveData(true) + + private var groupId: Long? = null + private var functionId: Long? = null + + override fun initViewModel(groupId: Long, functionId: Long) { + if (this.groupId != null) return + this.groupId = groupId + if (functionId != -1L) viewModelScope.launch { readExistingFunction(functionId) } + } + + private suspend fun readExistingFunction(functionId: Long) = withContext(io) { + withContext(ui) { isLoading.value = true } + val existingFunction = dataInteractor.getFunctionById(functionId) + if (existingFunction != null) { + this@AddFunctionViewModelImpl.functionId = functionId + withContext(ui) { + isUpdateMode.value = true + functionName.value = existingFunction.name + functionDescription.value = existingFunction.description + functionScript.value = existingFunction.script + //TODO dataSources: List, + } + } + withContext(ui) { isLoading.value = false } + } + + override fun setFunctionName(name: String) { + functionName.value = name + } + + override fun setFunctionDescription(description: String) { + functionDescription.value = description + } + + override fun setFunctionScript(body: String) { + //TODO validate the script and disable/enable the add button + functionScript.value = body + } + + override fun onCreateOrUpdateClicked() { + viewModelScope.launch(io) { + withContext(ui) { isLoading.value = true } + + //TODO validate the input first + if (functionId != null) updateFunction() + else dataInteractor.insertFunction(createDto()) + + withContext(ui) { + isLoading.value = false + finished.value = true + } + } + } + + private suspend fun updateFunction() { + //TODO update an existing function + //dataInteractor.updateFunction(...) + } + + private fun createDto() = FunctionDto( + id = 0L, + name = functionName.value!!, + featureId = 0L, + dataSources = emptyList(),//TODO implement data sources list + script = functionScript.value!!, + groupId = groupId!!, + displayIndex = 0, + description = functionDescription.value!!, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphsandstats/GraphStatAdapter.kt b/app/src/main/java/com/samco/trackandgraph/graphsandstats/GraphStatAdapter.kt deleted file mode 100644 index 26d6c2156..000000000 --- a/app/src/main/java/com/samco/trackandgraph/graphsandstats/GraphStatAdapter.kt +++ /dev/null @@ -1,144 +0,0 @@ -package com.samco.trackandgraph.graphsandstats - -import android.app.Application -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import android.widget.PopupMenu -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.samco.trackandgraph.R -import com.samco.trackandgraph.database.TrackAndGraphDatabase -import com.samco.trackandgraph.database.TrackAndGraphDatabaseDao -import com.samco.trackandgraph.database.GraphOrStat -import com.samco.trackandgraph.database.GraphStatType -import com.samco.trackandgraph.ui.GraphStatCardView -import com.samco.trackandgraph.ui.OrderedListAdapter -import kotlinx.coroutines.* - -class GraphStatAdapter(private val clickListener: GraphStatClickListener, application: Application) - : OrderedListAdapter(GraphStatDiffCallback()) { - private val dataSource = TrackAndGraphDatabase.getInstance(application).trackAndGraphDatabaseDao - - override fun onBindViewHolder(holder: GraphStatViewHolder, position: Int) { - holder.bind(getItem(position), clickListener, dataSource) - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GraphStatViewHolder { - return GraphStatViewHolder.from(parent) - } -} - -class GraphStatViewHolder(private val graphStatView: GraphStatCardView) - : RecyclerView.ViewHolder(graphStatView), PopupMenu.OnMenuItemClickListener { - private var currJob: Job? = null - private var clickListener: GraphStatClickListener? = null - private var graphStat: GraphOrStat? = null - private var dropElevation = 0f - - fun bind(graphStat: GraphOrStat, clickListener: GraphStatClickListener, dataSource: TrackAndGraphDatabaseDao) { - this.dropElevation = graphStatView.cardView.cardElevation - this.graphStat = graphStat - this.clickListener = clickListener - currJob?.cancel() - currJob = Job() - graphStatView.menuButtonClickListener = { v -> createContextMenu(v) } - graphStatView.setOnClickListener { clickListener.onClick(graphStat) } - CoroutineScope(Dispatchers.Main + currJob!!).launch { - if (!when (graphStat.type) { - GraphStatType.LINE_GRAPH -> tryInitLineGraph(dataSource, graphStat) - GraphStatType.PIE_CHART -> tryInitPieChart(dataSource, graphStat) - GraphStatType.TIME_SINCE -> tryInitTimeSinceStat(dataSource, graphStat) - GraphStatType.AVERAGE_TIME_BETWEEN -> tryInitAverageTimeBetween(dataSource, graphStat) - }) graphStatView.initError(graphStat, R.string.graph_stat_view_not_found) - } - } - - fun elevateCard() { - graphStatView.cardView.postDelayed({ - graphStatView.cardView.cardElevation = graphStatView.cardView.cardElevation * 3f - }, 10) - } - - fun dropCard() { - graphStatView.cardView.cardElevation = dropElevation - } - - private fun createContextMenu(view: View) { - val popup = PopupMenu(view.context, view) - popup.menuInflater.inflate(R.menu.edit_graph_stat_context_menu, popup.menu) - popup.setOnMenuItemClickListener(this) - popup.show() - } - - override fun onMenuItemClick(item: MenuItem?): Boolean { - graphStat?.let { - when (item?.itemId) { - R.id.edit -> clickListener?.onEdit(it) - R.id.delete -> clickListener?.onDelete(it) - else -> {} - } - } - return false - } - - private suspend fun tryInitPieChart(dataSource: TrackAndGraphDatabaseDao, graphStat: GraphOrStat): Boolean { - val pieChart = withContext(Dispatchers.IO) { - dataSource.getPieChartByGraphStatId(graphStat.id) - } ?: return false - graphStatView.initFromPieChart(graphStat, pieChart) - return true - } - - private suspend fun tryInitAverageTimeBetween(dataSource: TrackAndGraphDatabaseDao, graphStat: GraphOrStat): Boolean { - val avTimeStat = withContext(Dispatchers.IO) { - dataSource.getAverageTimeBetweenStatByGraphStatId(graphStat.id) - } ?: return false - graphStatView.initAverageTimeBetweenStat(graphStat, avTimeStat) - return true - } - - private suspend fun tryInitTimeSinceStat(dataSource: TrackAndGraphDatabaseDao, graphStat: GraphOrStat): Boolean { - val timeSinceStat = withContext(Dispatchers.IO) { - dataSource.getTimeSinceLastStatByGraphStatId(graphStat.id) - } ?: return false - graphStatView.initTimeSinceStat(graphStat, timeSinceStat) - return true - } - - private suspend fun tryInitLineGraph(dataSource: TrackAndGraphDatabaseDao, graphStat: GraphOrStat): Boolean { - val lineGraph = withContext(Dispatchers.IO) { - dataSource.getLineGraphByGraphStatId(graphStat.id) - } ?: return false - graphStatView.initFromLineGraph(graphStat, lineGraph, true) - return true - } - - companion object { - fun from(parent: ViewGroup): GraphStatViewHolder { - val graphStat = GraphStatCardView(parent.context) - graphStat.layoutParams = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.WRAP_CONTENT - ) - return GraphStatViewHolder(graphStat) - } - } -} - -class GraphStatDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: GraphOrStat, newItem: GraphOrStat) = oldItem.id == newItem.id - - override fun areContentsTheSame(oldItem: GraphOrStat, newItem: GraphOrStat) = oldItem == newItem -} - -class GraphStatClickListener(private val onDelete: (graphStat: GraphOrStat) -> Unit, - private val onEdit: (graphStat: GraphOrStat) -> Unit, - private val onClick: (graphStat: GraphOrStat) -> Unit - ) { - fun onDelete(graphStat: GraphOrStat) = onDelete.invoke(graphStat) - fun onEdit(graphStat: GraphOrStat) = onEdit.invoke(graphStat) - fun onClick(graphStat: GraphOrStat) = onClick.invoke(graphStat) -} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphsandstats/GraphsAndStatsFragment.kt b/app/src/main/java/com/samco/trackandgraph/graphsandstats/GraphsAndStatsFragment.kt deleted file mode 100644 index cd62961a2..000000000 --- a/app/src/main/java/com/samco/trackandgraph/graphsandstats/GraphsAndStatsFragment.kt +++ /dev/null @@ -1,209 +0,0 @@ -package com.samco.trackandgraph.graphsandstats - -import android.app.Activity -import android.os.Bundle -import android.view.* -import androidx.fragment.app.Fragment -import androidx.databinding.DataBindingUtil -import androidx.lifecycle.* -import androidx.navigation.NavController -import androidx.navigation.findNavController -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView - -import com.samco.trackandgraph.R -import com.samco.trackandgraph.database.GraphOrStat -import com.samco.trackandgraph.database.TrackAndGraphDatabase -import com.samco.trackandgraph.database.TrackAndGraphDatabaseDao -import com.samco.trackandgraph.database.GraphStatType -import com.samco.trackandgraph.databinding.GraphsAndStatsFragmentBinding -import com.samco.trackandgraph.ui.GroupViewHolder -import kotlinx.coroutines.* -import timber.log.Timber - -class GraphsAndStatsFragment : Fragment() { - private var navController: NavController? = null - private val args: GraphsAndStatsFragmentArgs by navArgs() - private lateinit var viewModel: GraphsAndStatsViewModel - private lateinit var binding: GraphsAndStatsFragmentBinding - private lateinit var adapter: GraphStatAdapter - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - this.navController = container?.findNavController() - viewModel = ViewModelProviders.of(this).get(GraphsAndStatsViewModel::class.java) - binding = DataBindingUtil.inflate(inflater, R.layout.graphs_and_stats_fragment, container, false) - binding.lifecycleOwner = viewLifecycleOwner - - viewModel = ViewModelProviders.of(this).get(GraphsAndStatsViewModel::class.java) - viewModel.initViewModel(requireActivity(), args.graphStatGroupId) - - adapter = GraphStatAdapter( - GraphStatClickListener( - viewModel::deleteGraphStat, - this::onEditGraphStat, - this::onGraphStatClicked - ), - activity!!.application - ) - binding.graphStatList.adapter = adapter - ItemTouchHelper(getDragTouchHelper()).attachToRecyclerView(binding.graphStatList) - binding.graphStatList.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) - listenToViewModelState() - - setHasOptionsMenu(true) - return binding.root - } - - private fun getDragTouchHelper() = object : ItemTouchHelper.Callback() { - override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { - return makeFlag(ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN) - } - - override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { - adapter.moveItem(viewHolder.adapterPosition, target.adapterPosition) - return true - } - - override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { - super.onSelectedChanged(viewHolder, actionState) - if (viewHolder != null && viewHolder is GraphStatViewHolder && actionState == ItemTouchHelper.ACTION_STATE_DRAG) { - viewHolder.elevateCard() - } - } - - override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { - super.clearView(recyclerView, viewHolder) - (viewHolder as GraphStatViewHolder).dropCard() - viewModel.adjustDisplayIndexes(adapter.getItems()) - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { } - } - - private fun onGraphStatClicked(graphOrStat: GraphOrStat) { - navController?.navigate(GraphsAndStatsFragmentDirections.actionViewGraphStat(graphOrStat.id)) - } - - private fun listenToViewModelState() { - viewModel.state.observe(this, Observer { - when (it) { - GraphsAndStatsViewState.INITIALIZING -> { - binding.graphStatsProgressBar.visibility = View.VISIBLE - } - GraphsAndStatsViewState.WAITING -> { - binding.graphStatsProgressBar.visibility = View.GONE - observeGraphStatsAndUpdate(adapter) - } - } - }) - } - - private fun onEditGraphStat(graphOrStat: GraphOrStat) { - navController?.navigate( - GraphsAndStatsFragmentDirections.actionGraphStatInput( - graphStatGroupId = args.graphStatGroupId, - graphStatId = graphOrStat.id - ) - ) - } - - private fun observeGraphStatsAndUpdate(adapter: GraphStatAdapter) { - adapter.registerAdapterDataObserver(object: RecyclerView.AdapterDataObserver() { - override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - binding.graphStatList.smoothScrollToPosition(0) - } - }) - viewModel.graphStats.observe(viewLifecycleOwner, Observer { - it?.let { adapter.submitList(it.toMutableList()) } - if (it.isNullOrEmpty()) { - binding.noGraphsHintText.text = getString(R.string.no_graph_stats_hint) - binding.noGraphsHintText.visibility = View.VISIBLE - } else { - binding.noGraphsHintText.visibility = View.INVISIBLE - } - }) - } - - private fun onAddClicked() { - navController?.navigate( - GraphsAndStatsFragmentDirections.actionGraphStatInput(graphStatGroupId = args.graphStatGroupId) - ) - } - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.graphs_and_stats_menu, menu) - super.onCreateOptionsMenu(menu, inflater) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.add -> onAddClicked() - } - return super.onOptionsItemSelected(item) - } - -} - -enum class GraphsAndStatsViewState { INITIALIZING, WAITING } -class GraphsAndStatsViewModel : ViewModel() { - private var dataSource: TrackAndGraphDatabaseDao? = null - lateinit var graphStats: LiveData> - - private val job = Job() - private val ioScope = CoroutineScope(Dispatchers.IO + job) - - val state: LiveData get() { return _state } - private val _state = MutableLiveData(GraphsAndStatsViewState.INITIALIZING) - - - fun initViewModel(activity: Activity, graphStatGroupId: Long) { - if (dataSource != null) return - _state.value = GraphsAndStatsViewState.INITIALIZING - dataSource = TrackAndGraphDatabase.getInstance(activity.application).trackAndGraphDatabaseDao - graphStats = dataSource!!.getGraphsAndStatsByGroupId(graphStatGroupId) - preenGraphStats() - } - - private fun preenGraphStats() { - ioScope.launch { - val graphStats = dataSource!!.getAllGraphStatsSync() - graphStats.forEach { - when (it.type) { - GraphStatType.LINE_GRAPH -> if (preenLineGraph(it)) dataSource!!.deleteGraphOrStat(it) - GraphStatType.PIE_CHART -> if (preenPieChart(it)) dataSource!!.deleteGraphOrStat(it) - GraphStatType.AVERAGE_TIME_BETWEEN -> if (preenAverageTimeBetween(it)) dataSource!!.deleteGraphOrStat(it) - GraphStatType.TIME_SINCE -> if (preenTimeSince(it)) dataSource!!.deleteGraphOrStat(it) - } - } - withContext(Dispatchers.Main) { _state.value = GraphsAndStatsViewState.WAITING } - } - } - - private fun preenLineGraph(graphOrStat: GraphOrStat): Boolean { - val lineGraph = dataSource!!.getLineGraphByGraphStatId(graphOrStat.id) ?: return true - return lineGraph.features.any { dataSource!!.tryGetFeatureById(it.featureId) == null } - } - - private fun preenPieChart(graphOrStat: GraphOrStat): Boolean { - return dataSource!!.getPieChartByGraphStatId(graphOrStat.id) == null - } - - private fun preenAverageTimeBetween(graphOrStat: GraphOrStat): Boolean { - return dataSource!!.getAverageTimeBetweenStatByGraphStatId(graphOrStat.id) == null - } - - private fun preenTimeSince(graphOrStat: GraphOrStat): Boolean { - return dataSource!!.getTimeSinceLastStatByGraphStatId(graphOrStat.id) == null - } - - fun deleteGraphStat(graphOrStat: GraphOrStat) { - ioScope.launch { dataSource?.deleteGraphOrStat(graphOrStat) } - } - - fun adjustDisplayIndexes(graphStats: List) = ioScope.launch { - val newGraphStats = graphStats.mapIndexed { i, gs -> gs.copy(displayIndex = i) } - dataSource?.updateGraphStats(newGraphStats) - } -} diff --git a/app/src/main/java/com/samco/trackandgraph/graphsandstats/ViewGraphStatFragment.kt b/app/src/main/java/com/samco/trackandgraph/graphsandstats/ViewGraphStatFragment.kt deleted file mode 100644 index 43a3cbf28..000000000 --- a/app/src/main/java/com/samco/trackandgraph/graphsandstats/ViewGraphStatFragment.kt +++ /dev/null @@ -1,129 +0,0 @@ -package com.samco.trackandgraph.graphsandstats - -import android.app.Activity -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import androidx.fragment.app.Fragment -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProviders -import androidx.navigation.NavController -import androidx.navigation.findNavController -import androidx.navigation.fragment.navArgs -import com.samco.trackandgraph.R -import com.samco.trackandgraph.database.* -import com.samco.trackandgraph.ui.GraphStatScrollView -import kotlinx.coroutines.* -import androidx.appcompat.app.AppCompatActivity - - -class ViewGraphStatFragment : Fragment() { - private var navController: NavController? = null - private lateinit var viewModel: ViewGraphStatViewModel - private lateinit var graphStatView: GraphStatScrollView - private val args: ViewGraphStatFragmentArgs by navArgs() - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - this.navController = container?.findNavController() - viewModel = ViewModelProviders.of(this).get(ViewGraphStatViewModel::class.java) - viewModel.init(activity!!, args.graphStatId) - graphStatView = GraphStatScrollView(context!!) - graphStatView.layoutParams = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT - ) - graphStatView.addLineGraphPanAndZoom() - listenToState() - return graphStatView - } - - private fun listenToState() { - viewModel.state.observe(this, Observer { - when (it) { - ViewGraphStatViewModelState.INITIALIZING -> graphStatView.initLoading() - ViewGraphStatViewModelState.WAITING -> initGraphStatViewFromViewModel() - } - }) - } - - override fun onResume() { - super.onResume() - (activity as AppCompatActivity).supportActionBar!!.hide() - } - - override fun onStop() { - super.onStop() - (activity as AppCompatActivity).supportActionBar!!.show() - } - - private fun initGraphStatViewFromViewModel() { - val graphStat = viewModel.graphStatObject - if (graphStat == null) graphStatView.initError(null, R.string.graph_stat_view_not_found) - when (viewModel.graphStatInnerObject) { - null -> graphStatView.initError(null, R.string.graph_stat_view_not_found) - is LineGraph -> graphStatView.initFromLineGraph(graphStat!!, viewModel.graphStatInnerObject as LineGraph) - is PieChart -> graphStatView.initFromPieChart(graphStat!!, viewModel.graphStatInnerObject as PieChart) - is TimeSinceLastStat -> graphStatView.initTimeSinceStat(graphStat!!, viewModel.graphStatInnerObject as TimeSinceLastStat) - is AverageTimeBetweenStat -> graphStatView.initAverageTimeBetweenStat(graphStat!!, viewModel.graphStatInnerObject as AverageTimeBetweenStat) - else -> graphStatView.initError(null, R.string.graph_stat_validation_unknown) - } - } -} - -enum class ViewGraphStatViewModelState { INITIALIZING, WAITING } -class ViewGraphStatViewModel : ViewModel() { - var graphStatObject: GraphOrStat? = null - private set - var graphStatInnerObject: Any? = null - private set - - val state: LiveData get() { return _state } - private val _state = MutableLiveData(ViewGraphStatViewModelState.INITIALIZING) - - private val currJob = Job() - private val ioScope = CoroutineScope(Dispatchers.IO + currJob) - - private var dataSource: TrackAndGraphDatabaseDao? = null - - fun init(activity: Activity, graphStatId: Long) { - if (dataSource != null) return - dataSource = TrackAndGraphDatabase.getInstance(activity.application).trackAndGraphDatabaseDao - _state.value = ViewGraphStatViewModelState.INITIALIZING - ioScope.launch { - initFromGraphStatId(graphStatId) - withContext(Dispatchers.Main) { _state.value = ViewGraphStatViewModelState.WAITING } - } - } - - private suspend fun initFromGraphStatId(graphStatId: Long) { - graphStatObject = dataSource!!.getGraphStatById(graphStatId) - when (graphStatObject!!.type) { - GraphStatType.LINE_GRAPH -> initLineGraph() - GraphStatType.PIE_CHART -> initPieChart() - GraphStatType.TIME_SINCE -> initTimeSince() - GraphStatType.AVERAGE_TIME_BETWEEN -> initAverageTimeBetween() - } - withContext(Dispatchers.Main) { _state.value = ViewGraphStatViewModelState.WAITING } - } - - private fun initLineGraph() { - graphStatInnerObject = dataSource!!.getLineGraphByGraphStatId(graphStatObject!!.id) - } - - private fun initPieChart() { - graphStatInnerObject = dataSource!!.getPieChartByGraphStatId(graphStatObject!!.id) - } - - private fun initTimeSince() { - graphStatInnerObject = dataSource!!.getTimeSinceLastStatByGraphStatId(graphStatObject!!.id) - } - - private fun initAverageTimeBetween() { - graphStatInnerObject = dataSource!!.getAverageTimeBetweenStatByGraphStatId(graphStatObject!!.id) - } -} diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/GraphFeatureListItemView.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/GraphFeatureListItemView.kt deleted file mode 100644 index b813e0dbe..000000000 --- a/app/src/main/java/com/samco/trackandgraph/graphstatinput/GraphFeatureListItemView.kt +++ /dev/null @@ -1,162 +0,0 @@ -package com.samco.trackandgraph.graphstatinput - -import android.content.Context -import android.text.InputFilter -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.ImageView -import android.widget.LinearLayout -import androidx.core.content.ContextCompat.getColor -import androidx.core.widget.addTextChangedListener -import com.samco.trackandgraph.R -import com.samco.trackandgraph.database.* -import com.samco.trackandgraph.databinding.ListItemLineGraphFeatureBinding -import java.text.DecimalFormat - -class GraphFeatureListItemView( - context: Context, - private val features: List, - private val colorsList: List, - private val lineGraphFeature: LineGraphFeature -) : LinearLayout(context) { - private val binding = ListItemLineGraphFeatureBinding.inflate(LayoutInflater.from(context), this, true) - private var onRemoveListener: ((GraphFeatureListItemView) -> Unit)? = null - private var onUpdatedListener: ((GraphFeatureListItemView) -> Unit)? = null - private val decimalFormat = DecimalFormat("0.###############") - - init { - setupFeatureSpinner() - setupGraphFeatureName() - setupAveragingModeSpinner() - setupPlottingModeSpinner() - setupOffsetInput() - setupScaleInput() - setupRemoveButton() - setupColorSpinner(context) - } - - private fun setupGraphFeatureName() { - binding.lineGraphFeatureName.filters = arrayOf(InputFilter.LengthFilter(MAX_LINE_GRAPH_FEATURE_NAME_LENGTH)) - if (lineGraphFeature.name.isNotEmpty()) binding.lineGraphFeatureName.setText(lineGraphFeature.name) - binding.lineGraphFeatureName.addTextChangedListener { text -> - if(lineGraphFeature.name != text.toString()) { - lineGraphFeature.name = text.toString() - onUpdatedListener?.invoke(this@GraphFeatureListItemView) - } - } - } - - private fun setupColorSpinner(context: Context) { - binding.colorSpinner.adapter = CustomColorSpinnerAdapter(context, colorsList) - binding.colorSpinner.setSelection(lineGraphFeature.colorIndex) - binding.colorSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onNothingSelected(p0: AdapterView<*>?) {} - override fun onItemSelected(p0: AdapterView<*>?, p1: View?, index: Int, p3: Long) { - lineGraphFeature.colorIndex = index - onUpdatedListener?.invoke(this@GraphFeatureListItemView) - } - } - } - - private fun setupRemoveButton() { - binding.removeButton.setOnClickListener { - onRemoveListener?.invoke(this) - onUpdatedListener?.invoke(this@GraphFeatureListItemView) - } - } - - private fun setupScaleInput() { - binding.scaleInput.setText(decimalFormat.format(lineGraphFeature.scale)) - binding.scaleInput.addTextChangedListener { text -> - lineGraphFeature.scale = text.toString().toDoubleOrNull() ?: 1.toDouble() - onUpdatedListener?.invoke(this@GraphFeatureListItemView) - } - } - - private fun setupOffsetInput() { - binding.offsetInput.setText(decimalFormat.format(lineGraphFeature.offset)) - binding.offsetInput.addTextChangedListener { text -> - lineGraphFeature.offset = text.toString().toDoubleOrNull() ?: 0.toDouble() - onUpdatedListener?.invoke(this@GraphFeatureListItemView) - } - } - - private fun setupPlottingModeSpinner() { - var modeSpinnerIndex = LineGraphPlottingModes.values().indexOfFirst { m -> m == lineGraphFeature.plottingMode } - if (modeSpinnerIndex == -1) modeSpinnerIndex = 0 - binding.plottingModeSpinner.setSelection(modeSpinnerIndex) - binding.plottingModeSpinner.onItemSelectedListener = object: AdapterView.OnItemSelectedListener { - override fun onNothingSelected(p0: AdapterView<*>?) { } - override fun onItemSelected(p0: AdapterView<*>?, p1: View?, index: Int, p3: Long) { - lineGraphFeature.plottingMode = LineGraphPlottingModes.values()[index] - onUpdatedListener?.invoke(this@GraphFeatureListItemView) - } - } - } - - private fun setupAveragingModeSpinner() { - var modeSpinnerStartIndex = LineGraphAveraginModes.values().indexOfFirst { m -> m == lineGraphFeature.averagingMode} - if (modeSpinnerStartIndex == -1) modeSpinnerStartIndex = 0 - binding.averagingModeSpinner.setSelection(modeSpinnerStartIndex) - binding.averagingModeSpinner.onItemSelectedListener = object: AdapterView.OnItemSelectedListener { - override fun onNothingSelected(p0: AdapterView<*>?) { } - override fun onItemSelected(p0: AdapterView<*>?, p1: View?, index: Int, p3: Long) { - lineGraphFeature.averagingMode = LineGraphAveraginModes.values()[index] - onUpdatedListener?.invoke(this@GraphFeatureListItemView) - } - } - } - - private fun setupFeatureSpinner() { - val itemNames = features.map { ft -> "${ft.trackGroupName} -> ${ft.name}" } - val adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, itemNames) - binding.featureSpinner.adapter = adapter - var featureSpinnerStartIndex = features.indexOfFirst { f -> f.id == lineGraphFeature.featureId} - if (featureSpinnerStartIndex == -1) featureSpinnerStartIndex = 0 - binding.featureSpinner.setSelection(featureSpinnerStartIndex) - binding.featureSpinner.onItemSelectedListener = object: AdapterView.OnItemSelectedListener { - override fun onNothingSelected(p0: AdapterView<*>?) { } - override fun onItemSelected(p0: AdapterView<*>?, p1: View?, index: Int, p3: Long) { - onFeatureChangeNameUpdate(lineGraphFeature.featureId, lineGraphFeature.name, features[index].name) - lineGraphFeature.featureId = features[index].id - onUpdatedListener?.invoke(this@GraphFeatureListItemView) - } - } - } - - private fun onFeatureChangeNameUpdate(oldFeatureId: Long, oldFeatureName: String, newFeatureName: String) { - if (oldFeatureId == -1L || oldFeatureName == "") binding.lineGraphFeatureName.setText(newFeatureName) - val oldFeatureDBName = features.firstOrNull { f -> f.id == oldFeatureId }?.name ?: return - if (oldFeatureDBName == oldFeatureName || oldFeatureName == "") { - binding.lineGraphFeatureName.setText(newFeatureName) - } - } - - fun setOnUpdateListener(onUpdatedListener: (GraphFeatureListItemView) -> Unit) { - this.onUpdatedListener = onUpdatedListener - } - - fun setOnRemoveListener(onRemoveListener: (GraphFeatureListItemView) -> Unit) { - this.onRemoveListener = onRemoveListener - } - - private class CustomColorSpinnerAdapter(context: Context, val colorIds: List) - : ArrayAdapter(context, R.layout.spinner_item_color) { - - override fun getCount() = colorIds.size - - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - var view = convertView ?: LayoutInflater.from(context) - .inflate(R.layout.spinner_item_color, parent, false) - view.findViewById(R.id.image).setColorFilter(getColor(context, colorIds[position])) - return view - } - - override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { - return getView(position, convertView, parent) - } - } -} diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/GraphStatInputFragment.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/GraphStatInputFragment.kt index 48b97e0e0..412b271d7 100644 --- a/app/src/main/java/com/samco/trackandgraph/graphstatinput/GraphStatInputFragment.kt +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/GraphStatInputFragment.kt @@ -1,623 +1,96 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ package com.samco.trackandgraph.graphstatinput -import android.app.Activity import android.os.Bundle -import android.os.Handler -import android.text.InputFilter import androidx.fragment.app.Fragment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.LinearLayout -import androidx.core.widget.addTextChangedListener -import androidx.databinding.DataBindingUtil +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.viewModels import androidx.lifecycle.* import androidx.navigation.NavController import androidx.navigation.findNavController import androidx.navigation.fragment.navArgs - +import com.samco.trackandgraph.MainActivity +import com.samco.trackandgraph.NavButtonStyle import com.samco.trackandgraph.R -import com.samco.trackandgraph.database.* -import com.samco.trackandgraph.databinding.FragmentGraphStatInputBinding -import kotlinx.coroutines.* -import org.threeten.bp.Duration -import java.lang.Exception -import java.text.DecimalFormat - +import com.samco.trackandgraph.settings.TngSettings +import com.samco.trackandgraph.ui.compose.compositionlocals.LocalSettings +import com.samco.trackandgraph.ui.compose.theming.TnGComposeTheme +import com.samco.trackandgraph.util.hideKeyboard +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint class GraphStatInputFragment : Fragment() { private var navController: NavController? = null private val args: GraphStatInputFragmentArgs by navArgs() - private lateinit var binding: FragmentGraphStatInputBinding - private lateinit var viewModel: GraphStatInputViewModel + private val viewModel: GraphStatInputViewModel by viewModels() - private val updateDemoHandler = Handler() - private val decimalFormat = DecimalFormat("0.###############") + @Inject + lateinit var tngSettings: TngSettings - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - this.navController = container?.findNavController() - binding = DataBindingUtil.inflate(inflater, R.layout.fragment_graph_stat_input, container, false) - binding.lifecycleOwner = viewLifecycleOwner - binding.graphStatNameInput.filters = arrayOf(InputFilter.LengthFilter(MAX_GRAPH_STAT_NAME_LENGTH)) - viewModel = ViewModelProviders.of(this).get(GraphStatInputViewModel::class.java) - viewModel.initViewModel(requireActivity(), args.graphStatGroupId, args.graphStatId) - binding.demoGraphStatView.hideMenuButton() - listenToViewModelState() - return binding.root - } - - private fun listenToViewModelState() { - viewModel.state.observe(this, Observer { - when (it) { - GraphStatInputState.INITIALIZING -> binding.inputProgressBar.visibility = View.VISIBLE - GraphStatInputState.WAITING -> { - binding.inputProgressBar.visibility = View.INVISIBLE - listenToUpdateMode() - listenToGraphTypeSpinner() - listenToGraphName() - listenToTimeDuration() - listenToAllFeatures() - listenToFormValid() - } - GraphStatInputState.ADDING -> binding.inputProgressBar.visibility = View.VISIBLE - else -> navController?.popBackStack() - } - }) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.initViewModel(args.groupId, args.graphStatId) } - private fun listenToUpdateMode() { - viewModel.updateMode.observe(this, Observer { b -> - if (b) { - binding.addBar.addButton.setText(R.string.update) - binding.graphStatTypeLayout.visibility = View.GONE - } + override fun onStart() { + super.onStart() + viewModel.complete.observe(viewLifecycleOwner, Observer { + if (it) navController?.popBackStack() }) } - private fun listenToGraphName() { - binding.graphStatNameInput.setText(viewModel.graphName.value) - binding.graphStatNameInput.addTextChangedListener { editText -> - viewModel.graphName.value = editText.toString() - onFormUpdate() - } - } - - private fun listenToAllFeatures() { - viewModel.allFeatures.observe(this, Observer { - initPieChartAdapter(it) - listenToAddLineGraphFeatureButton(it) - createLineGraphFeatureViews(it) - listenToValueStat(it) - listenToAddButton() - }) - } - - private fun listenToAddButton() { - binding.addBar.addButton.setOnClickListener { - viewModel.createGraphOrStat() - } - } - - private fun listenToValueStat(features: List) { - val itemNames = features.map { ft -> "${ft.trackGroupName} -> ${ft.name}" } - val adapter = ArrayAdapter(context!!, android.R.layout.simple_spinner_dropdown_item, itemNames) - binding.valueStatFeatureSpinner.adapter = adapter - val selected = viewModel.selectedValueStatFeature.value - if (selected != null) binding.valueStatFeatureSpinner.setSelection(features.indexOf(selected)) - binding.valueStatFeatureSpinner.onItemSelectedListener = object: AdapterView.OnItemSelectedListener { - override fun onNothingSelected(p0: AdapterView<*>?) { } - override fun onItemSelected(p0: AdapterView<*>?, p1: View?, index: Int, p3: Long) { - viewModel.selectedValueStatFeature.value = features[index] - onFormUpdate() - } - } - listenToValueStatFeature() - } - - private fun listenToValueStatFeature() { - binding.valueStatDiscreteValueInputLayout.visibility = View.GONE - binding.valueStatContinuousValueInputLayout.visibility = View.GONE - viewModel.selectedValueStatFeature.observe(this, Observer { - it?.let { - if (it.featureType == FeatureType.DISCRETE) { - binding.valueStatDiscreteValueInputLayout.visibility = View.VISIBLE - binding.valueStatContinuousValueInputLayout.visibility = View.GONE - } - else { - binding.valueStatDiscreteValueInputLayout.visibility = View.GONE - binding.valueStatContinuousValueInputLayout.visibility = View.VISIBLE - } - onFormUpdate() - } - }) - listenToValueStatDiscreteValueSpinner() - listenToValueStatContinuousRange() - } - - private fun listenToValueStatDiscreteValueSpinner() { - viewModel.selectedValueStatFeature.observe(this, Observer { - if(it != null && it.featureType == FeatureType.DISCRETE) { - val itemNames = it.discreteValues.map { dv -> dv.label } - val adapter = ArrayAdapter(context!!, android.R.layout.simple_spinner_dropdown_item, itemNames) - binding.valueStatDiscreteValueSpinner.adapter = adapter - val selected = viewModel.selectedValueStatDiscreteValue.value - val index = it.discreteValues.indexOf(selected) - if (selected != null && index >= 0) binding.valueStatDiscreteValueSpinner.setSelection(index) - else binding.valueStatDiscreteValueSpinner.setSelection(0) - binding.valueStatDiscreteValueSpinner.onItemSelectedListener = object: AdapterView.OnItemSelectedListener { - override fun onNothingSelected(p0: AdapterView<*>?) { } - override fun onItemSelected(p0: AdapterView<*>?, p1: View?, index: Int, p3: Long) { - viewModel.selectedValueStatDiscreteValue.value = it.discreteValues[index] - onFormUpdate() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + navController = container?.findNavController() + return ComposeView(requireContext()).apply { + setContent { + CompositionLocalProvider(LocalSettings provides tngSettings) { + TnGComposeTheme { + GraphStatInputView( + viewModelStoreOwner = this@GraphStatInputFragment, + viewModel = viewModel, + graphStatId = args.graphStatId + ) } } } - }) - } - - private fun listenToValueStatContinuousRange() { - if (viewModel.selectedValueStatToValue.value != null) - binding.valueStatToInput.setText(decimalFormat.format(viewModel.selectedValueStatToValue.value!!)) - binding.valueStatToInput.addTextChangedListener { editText -> - viewModel.selectedValueStatToValue.value = editText.toString().toDoubleOrNull() ?: 0.toDouble() - onFormUpdate() - } - if (viewModel.selectedValueStatFromValue.value != null) - binding.valueStatFromInput.setText(decimalFormat.format(viewModel.selectedValueStatFromValue.value!!)) - binding.valueStatFromInput.addTextChangedListener { editText -> - viewModel.selectedValueStatFromValue.value = editText.toString().toDoubleOrNull() ?: 0.toDouble() - onFormUpdate() - } - } - - private fun listenToAddLineGraphFeatureButton(features: List) { - binding.addFeatureButton.isClickable = true - binding.addFeatureButton.setOnClickListener { - val nextColorIndex = (viewModel.lineGraphFeatures.size * dataVisColorGenerator) % dataVisColorList.size - val newLineGraphFeature = LineGraphFeature(-1, "", nextColorIndex, - LineGraphAveraginModes.NO_AVERAGING, LineGraphPlottingModes.WHEN_TRACKED, 0.toDouble(), 1.toDouble()) - viewModel.lineGraphFeatures = viewModel.lineGraphFeatures.plus(newLineGraphFeature) - inflateLineGraphFeatureView(newLineGraphFeature, features) - } - binding.scrollView.fullScroll(View.FOCUS_DOWN) - } - - private fun createLineGraphFeatureViews(features: List) { - viewModel.lineGraphFeatures.forEach { lgf -> inflateLineGraphFeatureView(lgf, features) } - } - - private fun inflateLineGraphFeatureView(lgf: LineGraphFeature, features: List) { - val view = GraphFeatureListItemView(context!!, features, dataVisColorList, lgf) - view.setOnRemoveListener { - viewModel.lineGraphFeatures = viewModel.lineGraphFeatures.minus(lgf) - binding.lineGraphFeaturesLayout.removeView(view) - binding.addFeatureButton.isEnabled = true - } - view.setOnUpdateListener { onFormUpdate() } - val params = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - view.layoutParams = params - binding.lineGraphFeaturesLayout.addView(view) - binding.lineGraphFeaturesLayout.post { - binding.scrollView.fullScroll(View.FOCUS_DOWN) - view.requestFocus() - } - if (viewModel.lineGraphFeatures.size == MAX_LINE_GRAPH_FEATURES) { - binding.addFeatureButton.isEnabled = false - } - } - - private fun initPieChartAdapter(features: List) { - val discreteFeatures = features - .filter { f -> f.featureType == FeatureType.DISCRETE } - val itemNames = discreteFeatures - .map { ft -> "${ft.trackGroupName} -> ${ft.name}" } - val adapter = ArrayAdapter(context!!, android.R.layout.simple_spinner_dropdown_item, itemNames) - binding.pieChartFeatureSpinner.adapter = adapter - val selected = viewModel.selectedPieChartFeature.value - if (selected != null) binding.pieChartFeatureSpinner.setSelection(discreteFeatures.indexOf(selected)) - binding.pieChartFeatureSpinner.onItemSelectedListener = object: AdapterView.OnItemSelectedListener { - override fun onNothingSelected(p0: AdapterView<*>?) { } - override fun onItemSelected(p0: AdapterView<*>?, p1: View?, index: Int, p3: Long) { - viewModel.selectedPieChartFeature.value = discreteFeatures[index] - onFormUpdate() - } } } - private fun listenToTimeDuration() { - //TODO move these types of declarations into their corresponding data class files - val timeDurations = listOf( - null, - Duration.ofDays(1), - Duration.ofDays(7), - Duration.ofDays(31), - Duration.ofDays(93), - Duration.ofDays(183), - Duration.ofDays(365) + override fun onResume() { + super.onResume() + (requireActivity() as MainActivity).setActionBarConfig( + NavButtonStyle.UP, + getString(R.string.add_a_graph_or_stat) ) - binding.sampleDurationSpinner.setSelection(timeDurations.indexOf(viewModel.sampleDuration.value)) - binding.sampleDurationSpinner.onItemSelectedListener = object: AdapterView.OnItemSelectedListener { - override fun onNothingSelected(p0: AdapterView<*>?) { } - override fun onItemSelected(p0: AdapterView<*>?, p1: View?, index: Int, p3: Long) { - viewModel.sampleDuration.value = timeDurations[index] - onFormUpdate() - } - } - } - - private fun listenToGraphTypeSpinner() { - val graphTypes = listOf( - GraphStatType.LINE_GRAPH, - GraphStatType.PIE_CHART, - GraphStatType.AVERAGE_TIME_BETWEEN, - GraphStatType.TIME_SINCE - ) - binding.graphTypeSpinner.setSelection(graphTypes.indexOf(viewModel.graphStatType.value)) - updateViewForSelectedGraphStatType(viewModel.graphStatType.value!!) - binding.graphTypeSpinner.onItemSelectedListener = object: AdapterView.OnItemSelectedListener { - override fun onNothingSelected(p0: AdapterView<*>?) { } - override fun onItemSelected(p0: AdapterView<*>?, p1: View?, index: Int, p3: Long) { - val graphStatType = graphTypes[index] - viewModel.graphStatType.value = graphStatType - updateViewForSelectedGraphStatType(graphStatType) - } - } - } - - private fun updateViewForSelectedGraphStatType(graphStatType: GraphStatType) { - onFormUpdate() - when(graphStatType) { - GraphStatType.LINE_GRAPH -> initLineGraphView() - GraphStatType.PIE_CHART -> initPieChartView() - GraphStatType.AVERAGE_TIME_BETWEEN -> initAverageTimeBetweenView() - GraphStatType.TIME_SINCE -> initTimeSinceView() - } - } - - private fun initTimeSinceView() { - binding.timeDurationLayout.visibility = View.GONE - binding.pieChartSelectFeatureLayout.visibility = View.GONE - binding.addFeatureButton.visibility = View.GONE - binding.lineGraphFeaturesLayout.visibility = View.GONE - binding.valueStatInputLayout.visibility = View.VISIBLE - } - - private fun initAverageTimeBetweenView() { - binding.timeDurationLayout.visibility = View.VISIBLE - binding.pieChartSelectFeatureLayout.visibility = View.GONE - binding.addFeatureButton.visibility = View.GONE - binding.lineGraphFeaturesLayout.visibility = View.GONE - binding.valueStatInputLayout.visibility = View.VISIBLE - } - - private fun initPieChartView() { - binding.timeDurationLayout.visibility = View.VISIBLE - binding.pieChartSelectFeatureLayout.visibility = View.VISIBLE - binding.addFeatureButton.visibility = View.GONE - binding.lineGraphFeaturesLayout.visibility = View.GONE - binding.valueStatInputLayout.visibility = View.GONE - } - - private fun initLineGraphView() { - binding.timeDurationLayout.visibility = View.VISIBLE - binding.pieChartSelectFeatureLayout.visibility = View.GONE - binding.addFeatureButton.visibility = View.VISIBLE - binding.lineGraphFeaturesLayout.visibility = View.VISIBLE - binding.valueStatInputLayout.visibility = View.GONE - } - - private fun listenToFormValid() { - viewModel.formValid.observe(this, Observer { errorNow -> - binding.addBar.addButton.isEnabled = errorNow == null - binding.addBar.errorText.postDelayed({ - val errorThen = viewModel.formValid.value - val text = errorThen?.let { getString(it.errorMessageId) } ?: "" - binding.addBar.errorText.text = text - }, 200) - }) - } - - private fun onFormUpdate() { - viewModel.validateForm() - updateDemoView() - } - - private fun updateDemoView() { - updateDemoHandler.removeCallbacksAndMessages(null) - updateDemoHandler.postDelayed(Runnable { - if (viewModel.formValid.value != null) { - binding.demoGraphStatView.initInvalid() - } else { - val graphOrStat = viewModel.constructGraphOrStat() - when (viewModel.graphStatType.value) { - GraphStatType.LINE_GRAPH -> binding.demoGraphStatView - .initFromLineGraph(graphOrStat, viewModel.constructLineGraph(graphOrStat.id)) - GraphStatType.PIE_CHART -> binding.demoGraphStatView - .initFromPieChart(graphOrStat, viewModel.constructPieChart(graphOrStat.id)) - GraphStatType.AVERAGE_TIME_BETWEEN -> binding.demoGraphStatView - .initAverageTimeBetweenStat(graphOrStat, viewModel.constructAverageTimeBetween(graphOrStat.id)) - GraphStatType.TIME_SINCE -> binding.demoGraphStatView - .initTimeSinceStat(graphOrStat, viewModel.constructTimeSince(graphOrStat.id)) - else -> binding.demoGraphStatView.initInvalid() - } - } - }, 500) } override fun onDestroyView() { - binding.demoGraphStatView.dispose() super.onDestroyView() - val imm = activity?.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view?.windowToken, 0) + requireActivity().window.hideKeyboard(requireActivity().currentFocus?.windowToken) } } -enum class GraphStatInputState { INITIALIZING, WAITING, ADDING, FINISHED } -class GraphStatInputViewModel : ViewModel() { - class ValidationException(val errorMessageId: Int): Exception() - - private var dataSource: TrackAndGraphDatabaseDao? = null - - private var updateJob = Job() - private val ioScope = CoroutineScope(Dispatchers.IO + updateJob) - - private var graphStatGroupId: Long = -1L - private var graphStatId: Long? = null - private var id: Long? = null - - val graphName = MutableLiveData("") - val graphStatType = MutableLiveData(GraphStatType.LINE_GRAPH) - val sampleDuration = MutableLiveData(null) - val selectedPieChartFeature = MutableLiveData(null) - val selectedValueStatFeature = MutableLiveData(null) - val selectedValueStatDiscreteValue = MutableLiveData(null) - val selectedValueStatFromValue = MutableLiveData(0.toDouble()) - val selectedValueStatToValue = MutableLiveData(0.toDouble()) - var lineGraphFeatures = listOf() - val updateMode: LiveData get() { return _updateMode } - private val _updateMode = MutableLiveData(false) - val state: LiveData get() { return _state } - private val _state = MutableLiveData(GraphStatInputState.INITIALIZING) - val formValid: LiveData get() { return _formValid } - private val _formValid = MutableLiveData(null) - lateinit var allFeatures: LiveData> private set - - - fun initViewModel(activity: Activity, graphStatGroupId: Long, graphStatId: Long) { - if (dataSource != null) return - this.graphStatGroupId = graphStatGroupId - _state.value = GraphStatInputState.INITIALIZING - dataSource = TrackAndGraphDatabase.getInstance(activity.application).trackAndGraphDatabaseDao - allFeatures = dataSource!!.getAllFeaturesAndTrackGroups() - if (graphStatId != -1L) initFromExistingGraphStat(graphStatId) - else _state.value = GraphStatInputState.WAITING - } - - private fun initFromExistingGraphStat(graphStatId: Long) { - ioScope.launch { - val graphStat = dataSource!!.getGraphStatById(graphStatId) - - if (graphStat == null) { - withContext(Dispatchers.Main) { _state.value = GraphStatInputState.WAITING } - return@launch - } - - val existingId = when(graphStat.type) { - GraphStatType.LINE_GRAPH -> tryInitLineGraph(graphStat) - GraphStatType.PIE_CHART -> tryInitPieChart(graphStat) - GraphStatType.TIME_SINCE -> tryInitTimeSinceStat(graphStat) - GraphStatType.AVERAGE_TIME_BETWEEN -> tryInitAverageTimeBetween(graphStat) - } - - if (existingId != null) withContext(Dispatchers.Main) { - this@GraphStatInputViewModel.graphName.value = graphStat.name - this@GraphStatInputViewModel.graphStatType.value = graphStat.type - this@GraphStatInputViewModel.graphStatId = graphStat.id - this@GraphStatInputViewModel.id = existingId - this@GraphStatInputViewModel._updateMode.value = true - } - withContext(Dispatchers.Main) { _state.value = GraphStatInputState.WAITING } - } - } - - private suspend fun tryInitPieChart(graphStat: GraphOrStat): Long? { - val pieChart = dataSource!!.getPieChartByGraphStatId(graphStat.id) ?: return null - val pieChartFeature = dataSource!!.getFeatureAndTrackGroupByFeatureId(pieChart.featureId) ?: return null - withContext(Dispatchers.Main) { - this@GraphStatInputViewModel.selectedPieChartFeature.value = pieChartFeature - this@GraphStatInputViewModel.sampleDuration.value = pieChart.duration - } - return pieChart.id - } - - private suspend fun tryInitAverageTimeBetween(graphStat: GraphOrStat): Long? { - val avTimeStat = dataSource!!.getAverageTimeBetweenStatByGraphStatId(graphStat.id) ?: return null - val feature = dataSource!!.getFeatureAndTrackGroupByFeatureId(avTimeStat.featureId) ?: return null - withContext(Dispatchers.Main) { - this@GraphStatInputViewModel.selectedValueStatFeature.value = feature - if (feature.featureType == FeatureType.DISCRETE) { - this@GraphStatInputViewModel.selectedValueStatDiscreteValue.value = - feature.discreteValues.firstOrNull { dv -> dv.index == avTimeStat.fromValue.toInt() } - } else { - this@GraphStatInputViewModel.selectedValueStatFromValue.value = avTimeStat.fromValue.toDouble() - this@GraphStatInputViewModel.selectedValueStatToValue.value = avTimeStat.toValue.toDouble() - } - this@GraphStatInputViewModel.sampleDuration.value = avTimeStat.duration - } - return avTimeStat.id - } - - private suspend fun tryInitTimeSinceStat(graphStat: GraphOrStat): Long? { - val timeSinceStat = dataSource!!.getTimeSinceLastStatByGraphStatId(graphStat.id) ?: return null - val feature = dataSource!!.getFeatureAndTrackGroupByFeatureId(timeSinceStat.featureId) ?: return null - withContext(Dispatchers.Main) { - this@GraphStatInputViewModel.selectedValueStatFeature.value = feature - if (feature.featureType == FeatureType.DISCRETE) { - this@GraphStatInputViewModel.selectedValueStatDiscreteValue.value = - feature.discreteValues.firstOrNull { dv -> dv.index == timeSinceStat.fromValue.toInt() } - } else { - this@GraphStatInputViewModel.selectedValueStatFromValue.value = timeSinceStat.fromValue.toDouble() - this@GraphStatInputViewModel.selectedValueStatToValue.value = timeSinceStat.toValue.toDouble() - } - } - return timeSinceStat.id - } - - private suspend fun tryInitLineGraph(graphStat: GraphOrStat): Long? { - val lineGraph = dataSource!!.getLineGraphByGraphStatId(graphStat.id) ?: return null - withContext(Dispatchers.Main) { - this@GraphStatInputViewModel.sampleDuration.value = lineGraph.duration - this@GraphStatInputViewModel.lineGraphFeatures = lineGraph.features - } - return lineGraph.id - } - - fun validateForm() { - try { - if (graphName.value!!.isEmpty()) throw ValidationException(R.string.graph_stat_validation_no_name) - when (graphStatType.value) { - GraphStatType.LINE_GRAPH -> validateLineGraph() - GraphStatType.PIE_CHART -> validatePieChart() - GraphStatType.TIME_SINCE -> validateTimeSince() - GraphStatType.AVERAGE_TIME_BETWEEN -> validateAverageTimeBetween() - else -> throw Exception("") - } - _formValid.value = null - } catch (e: Exception) { - if (e is ValidationException) _formValid.value = e - else _formValid.value = ValidationException(R.string.graph_stat_validation_unknown) - } - } - - private fun validateAverageTimeBetween() { validateValueStat() } - - private fun validateTimeSince() { validateValueStat() } - - private fun validateValueStat() { - if (selectedValueStatFeature.value == null) - throw ValidationException(R.string.graph_stat_validation_no_line_graph_features) - if (selectedValueStatFeature.value!!.featureType == FeatureType.DISCRETE) { - if (selectedValueStatDiscreteValue.value == null) - throw ValidationException(R.string.graph_stat_validation_invalid_value_stat_discrete_value) - if (!selectedValueStatFeature.value!!.discreteValues - .contains(selectedValueStatDiscreteValue.value!!)) - throw ValidationException(R.string.graph_stat_validation_invalid_value_stat_discrete_value) - } - if (selectedValueStatFeature.value!!.featureType == FeatureType.CONTINUOUS) { - if (selectedValueStatFromValue.value!! > selectedValueStatToValue.value!!) - throw ValidationException(R.string.graph_stat_validation_invalid_value_stat_from_to) - } - } - - private fun validatePieChart() { - if (selectedPieChartFeature.value == null - || selectedPieChartFeature.value!!.featureType != FeatureType.DISCRETE) - throw ValidationException(R.string.graph_stat_validation_no_line_graph_features) - } - - private fun validateLineGraph() { - if (lineGraphFeatures.isEmpty()) - throw ValidationException(R.string.graph_stat_validation_no_line_graph_features) - lineGraphFeatures.forEach { f -> - if (f.colorIndex !in dataVisColorList.indices) - throw ValidationException(R.string.graph_stat_validation_unrecognised_color) - if (allFeatures.value?.map { feat -> feat.id }?.contains(f.featureId) != true) - throw ValidationException(R.string.graph_stat_validation_invalid_line_graph_feature) - } - } - - fun createGraphOrStat() { - if (_state.value != GraphStatInputState.WAITING) return - _state.value = GraphStatInputState.ADDING - ioScope.launch { - val graphStatId = if (_updateMode.value!!) { - dataSource!!.updateGraphOrStat(constructGraphOrStat()) - graphStatId!! - } else { - shiftUpGraphStatViewIndexes() - dataSource!!.insertGraphOrStat(constructGraphOrStat()) - } - - when (graphStatType.value) { - GraphStatType.LINE_GRAPH -> addLineGraph(constructLineGraph(graphStatId)) - GraphStatType.PIE_CHART -> addPieChart(constructPieChart(graphStatId)) - GraphStatType.AVERAGE_TIME_BETWEEN -> addAvTimeBetween(constructAverageTimeBetween(graphStatId)) - GraphStatType.TIME_SINCE -> addTimeSince(constructTimeSince(graphStatId)) - } - withContext(Dispatchers.Main) { _state.value = GraphStatInputState.FINISHED } - } - } - - private fun shiftUpGraphStatViewIndexes() { - val newList = dataSource!!.getAllGraphStatsSync().map { - it.copy(displayIndex = it.displayIndex + 1) - } - dataSource!!.updateGraphStats(newList) - } - - private fun addLineGraph(lineGraph: LineGraph) { - if (_updateMode.value!!) dataSource!!.updateLineGraph(lineGraph) - else dataSource!!.insertLineGraph(lineGraph) - } - - private fun addPieChart(pieChart: PieChart) { - if (_updateMode.value!!) dataSource!!.updatePieChart(pieChart) - else dataSource!!.insertPieChart(pieChart) - } - - private fun addAvTimeBetween(averageTimeBetweenStat: AverageTimeBetweenStat) { - if (_updateMode.value!!) dataSource!!.updateAverageTimeBetweenStat(averageTimeBetweenStat) - else dataSource!!.insertAverageTimeBetweenStat(averageTimeBetweenStat) - } - - private fun addTimeSince(timeSinceLastStat: TimeSinceLastStat) { - if (_updateMode.value!!) dataSource!!.updateTimeSinceLastStat(timeSinceLastStat) - else dataSource!!.insertTimeSinceLastStat(timeSinceLastStat) - } - - override fun onCleared() { - super.onCleared() - ioScope.cancel() - } - - fun constructGraphOrStat() = GraphOrStat.create( - graphStatId ?: 0L, graphStatGroupId, graphName.value!!, graphStatType.value!!, 0 - ) - - fun constructLineGraph(graphStatId: Long) = LineGraph.create( - id ?: 0L, graphStatId, lineGraphFeatures, sampleDuration.value - ) - - fun constructPieChart(graphStatId: Long) = PieChart( - id ?: 0L, graphStatId, - selectedPieChartFeature.value!!.id, sampleDuration.value - ) - - fun constructAverageTimeBetween(graphStatId: Long) = AverageTimeBetweenStat( - id ?: 0L, graphStatId, - selectedValueStatFeature.value!!.id, getFromValue(), - getToValue(), sampleDuration.value - ) - - fun constructTimeSince(graphStatId: Long) = TimeSinceLastStat( - id ?: 0L, graphStatId, - selectedValueStatFeature.value!!.id, getFromValue(), getToValue() - ) - - private fun getFromValue(): String { - return if (selectedValueStatFeature.value!!.featureType == FeatureType.DISCRETE) - selectedValueStatDiscreteValue.value!!.index.toString() - else selectedValueStatFromValue.value!!.toString() - } - - private fun getToValue(): String { - return if (selectedValueStatFeature.value!!.featureType == FeatureType.DISCRETE) - selectedValueStatDiscreteValue.value!!.index.toString() - else selectedValueStatToValue.value!!.toString() - } -} diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/GraphStatInputView.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/GraphStatInputView.kt new file mode 100644 index 000000000..2ffdd1ffc --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/GraphStatInputView.kt @@ -0,0 +1,302 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +@file:OptIn(ExperimentalComposeUiApi::class) + +package com.samco.trackandgraph.graphstatinput + +import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.* +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModelStoreOwner +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.* +import com.samco.trackandgraph.graphstatinput.configviews.ui.* +import com.samco.trackandgraph.graphstatview.factories.viewdto.IGraphStatViewData +import com.samco.trackandgraph.graphstatview.ui.GraphStatCardView +import com.samco.trackandgraph.ui.compose.theming.tngColors +import com.samco.trackandgraph.ui.compose.ui.* +import java.lang.Float.max +import java.lang.Float.min + +@Composable +internal fun GraphStatInputView( + viewModelStoreOwner: ViewModelStoreOwner, + viewModel: GraphStatInputViewModel, + graphStatId: Long +) { + Surface(color = MaterialTheme.colors.background) { + Box { + val loading = viewModel.loading.observeAsState(true) + val demoViewData by viewModel.demoViewData.observeAsState() + + Column { + var demoYOffset by remember { mutableStateOf(0f) } + var demoVisible by remember { mutableStateOf(false) } + + Box(modifier = Modifier.weight(1f)) { + GraphStatInputViewForm( + viewModelStoreOwner = viewModelStoreOwner, + viewModel = viewModel, + graphStatId = graphStatId + ) + + if (demoVisible) demoViewData?.let { DemoOverlay(demoYOffset, it) } + } + + if (demoViewData != null) { + DemoButton( + isPressed = { + demoVisible = it + if (!it) demoYOffset = 0f + }, + onDragOffset = { demoYOffset -= it }) + } + + AddCreateBar( + errorText = viewModel.validationException.observeAsState().value?.errorMessageId, + onCreateUpdateClicked = viewModel::createGraphOrStat, + isUpdateMode = viewModel.updateMode.observeAsState().value ?: false + ) + } + + if (loading.value) LoadingOverlay() + } + } +} + +@Composable +private fun DemoOverlay( + demoYOffset: Float, + demoViewData: IGraphStatViewData, +) = Box( + Modifier + .background(MaterialTheme.tngColors.surface.copy(alpha = 0.8f)) + .fillMaxSize() +) { + var displayHeight by remember { mutableStateOf(0f) } + var demoHeight by remember { mutableStateOf(0f) } + Box( + Modifier + .fillMaxSize() + .onGloballyPositioned { + displayHeight = it.size.height.toFloat() + } + ) { + val invisiblePixels = max(0f, demoHeight - displayHeight) + val offsetRatio = max(0f, min(demoYOffset * 3f, displayHeight) / max(1f, displayHeight)) + val offset = -offsetRatio * invisiblePixels + + GraphStatCardView( + modifier = Modifier + .fillMaxWidth() + .offset(y = offset.dp) + .wrapContentHeight(align = Alignment.Top, unbounded = true) + .onSizeChanged { demoHeight = it.height.toFloat() }, + graphStatViewData = demoViewData + ) + } +} + + +@Composable +private fun DemoButton( + isPressed: (Boolean) -> Unit, + onDragOffset: (Float) -> Unit +) { + var buttonDown by remember { mutableStateOf(false) } + + val background = + if (buttonDown) MaterialTheme.tngColors.selectorButtonColor + else MaterialTheme.tngColors.primary + + Box( + Modifier + .background(background) + .fillMaxWidth() + .padding(vertical = dimensionResource(id = R.dimen.card_padding)) + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = true) + isPressed(true) + buttonDown = true + do { + val event = awaitPointerEvent() + event.changes.forEach { + if (it.positionChange() != Offset.Zero) { + onDragOffset(it.positionChange().y) + it.consume() + } + } + } while (event.changes.all { it.pressed }) + isPressed(false) + buttonDown = false + } + } + .wrapContentHeight(), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(id = R.string.hold_to_preview).uppercase(), + style = MaterialTheme.typography.h6, + color = MaterialTheme.tngColors.onPrimary + ) + } +} + +@Composable +private fun GraphStatInputViewForm( + modifier: Modifier = Modifier, + viewModelStoreOwner: ViewModelStoreOwner, + viewModel: GraphStatInputViewModel, + graphStatId: Long, + scrollState: ScrollState = rememberScrollState() +) = Column( + modifier = modifier + .padding( + top = dimensionResource(id = R.dimen.card_padding), + start = dimensionResource(id = R.dimen.card_padding), + end = dimensionResource(id = R.dimen.card_padding) + ) + .verticalScroll(state = scrollState) +) { + + FullWidthTextField( + textFieldValue = viewModel.graphName, + onValueChange = { viewModel.setGraphStatName(it) }, + label = stringResource(id = R.string.graph_or_stat_name) + ) + + DialogInputSpacing() + + val updateMode = viewModel.updateMode.observeAsState(false) + if (!updateMode.value) { + val selectedGraphType by viewModel.graphStatType.observeAsState(GraphStatType.LINE_GRAPH) + GraphStatTypeSelector( + selectedItem = selectedGraphType, + setGraphType = { viewModel.setGraphType(it) } + ) + } + + DialogInputSpacing() + Divider() + DialogInputSpacing() + + ConfigInputView( + viewModelStoreOwner = viewModelStoreOwner, + viewModel = viewModel, + graphStatId = graphStatId, + scrollState = scrollState + ) +} + +@Composable +fun GraphStatTypeSelector( + selectedItem: GraphStatType, + setGraphType: (GraphStatType) -> Unit +) { + LabeledRow( + label = stringResource(R.string.graph_type_label), + paddingValues = PaddingValues( + start = dimensionResource(id = R.dimen.card_padding) + ) + ) { + + val spinnerItems = mapOf( + GraphStatType.LINE_GRAPH to stringResource(id = R.string.graph_type_line_graph), + GraphStatType.BAR_CHART to stringResource(id = R.string.graph_type_bar_chart), + GraphStatType.PIE_CHART to stringResource(id = R.string.graph_type_pie_chart), + GraphStatType.AVERAGE_TIME_BETWEEN to stringResource(id = R.string.graph_type_average_time_between), + GraphStatType.TIME_HISTOGRAM to stringResource(id = R.string.graph_type_time_histogram), + GraphStatType.LAST_VALUE to stringResource(id = R.string.graph_type_last_value), + ) + + TextMapSpinner( + strings = spinnerItems, + selectedItem = selectedItem, + onItemSelected = { setGraphType(it) }, + ) + } +} + +@Composable +fun ConfigInputView( + viewModelStoreOwner: ViewModelStoreOwner, + viewModel: GraphStatInputViewModel, + graphStatId: Long, + scrollState: ScrollState +) { + val graphType by viewModel.graphStatType.observeAsState(GraphStatType.LINE_GRAPH) + + when (graphType) { + GraphStatType.LINE_GRAPH -> LineGraphConfigView( + scrollState = scrollState, + viewModelStoreOwner = viewModelStoreOwner, + graphStatId = graphStatId, + onConfigEvent = { viewModel.onConfigEvent(it) }, + ) + + GraphStatType.PIE_CHART -> PieChartConfigView( + scrollState = scrollState, + viewModelStoreOwner = viewModelStoreOwner, + graphStatId = graphStatId, + onConfigEvent = { viewModel.onConfigEvent(it) }, + ) + + GraphStatType.AVERAGE_TIME_BETWEEN -> AverageTimeBetweenConfigView( + scrollState = scrollState, + viewModelStoreOwner = viewModelStoreOwner, + graphStatId = graphStatId, + onConfigEvent = { viewModel.onConfigEvent(it) }, + ) + + GraphStatType.TIME_HISTOGRAM -> TimeHistogramConfigView( + scrollState = scrollState, + viewModelStoreOwner = viewModelStoreOwner, + graphStatId = graphStatId, + onConfigEvent = { viewModel.onConfigEvent(it) }, + ) + + GraphStatType.LAST_VALUE -> LastValueConfigView( + scrollState = scrollState, + viewModelStoreOwner = viewModelStoreOwner, + graphStatId = graphStatId, + onConfigEvent = { viewModel.onConfigEvent(it) }, + ) + + GraphStatType.BAR_CHART -> BarChartConfigView( + scrollState = scrollState, + viewModelStoreOwner = viewModelStoreOwner, + graphStatId = graphStatId, + onConfigEvent = { viewModel.onConfigEvent(it) }, + ) + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/GraphStatInputViewModel.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/GraphStatInputViewModel.kt new file mode 100644 index 000000000..be336fd83 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/GraphStatInputViewModel.kt @@ -0,0 +1,235 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatinput + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.* +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.* +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.DefaultDispatcher +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.base.model.di.MainDispatcher +import com.samco.trackandgraph.graphstatproviders.GraphStatInteractorProvider +import com.samco.trackandgraph.graphstatview.factories.viewdto.IGraphStatViewData +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import java.lang.Exception +import javax.inject.Inject + +sealed interface GraphStatConfigEvent { + data class ValidationException(val errorMessageId: Int) : Exception(), GraphStatConfigEvent + object Loading : GraphStatConfigEvent + + sealed interface ConfigData : GraphStatConfigEvent { + val config: T + + data class LineGraphConfigData( + override val config: LineGraphWithFeatures + ) : ConfigData + + data class PieChartConfigData( + override val config: PieChart + ) : ConfigData + + data class AverageTimeBetweenConfigData( + override val config: AverageTimeBetweenStat + ) : ConfigData + + data class TimeHistogramConfigData( + override val config: TimeHistogram + ) : ConfigData + + data class LastValueConfigData( + override val config: LastValueStat + ) : ConfigData + + data class BarChartConfigData( + override val config: BarChart + ) : ConfigData + } +} + +interface GraphStatInputViewModel { + val graphName: TextFieldValue + val graphStatType: LiveData + val updateMode: LiveData + val loading: LiveData + val validationException: LiveData + val demoViewData: LiveData + val complete: LiveData + + fun initViewModel(graphStatGroupId: Long, graphStatId: Long) + fun setGraphStatName(name: TextFieldValue) + fun setGraphType(type: GraphStatType) + + fun onConfigEvent(configData: GraphStatConfigEvent?) + fun createGraphOrStat() +} + +@HiltViewModel +class GraphStatInputViewModelImpl @Inject constructor( + private val dataInteractor: DataInteractor, + private val gsiProvider: GraphStatInteractorProvider, + @IODispatcher private val io: CoroutineDispatcher, + @MainDispatcher private val ui: CoroutineDispatcher, + @DefaultDispatcher private val worker: CoroutineDispatcher +) : ViewModel(), GraphStatInputViewModel { + + private val thisIsLoading = MutableStateFlow(false) + private val configIsLoading = MutableStateFlow(false) + + override var graphName by mutableStateOf(TextFieldValue("")) + override val graphStatType = MutableLiveData(GraphStatType.LINE_GRAPH) + override val updateMode = MutableLiveData(false) + override val loading = combine(thisIsLoading, configIsLoading) { a, b -> a || b } + .asLiveData(viewModelScope.coroutineContext) + override val validationException = + MutableLiveData(null) + override val demoViewData = MutableLiveData(null) + override val complete = MutableLiveData(false) + + private var updateJob: Job? = null + private var configData: GraphStatConfigEvent.ConfigData<*>? = null + private var graphStatGroupId: Long = -1L + private var graphStatId: Long? = null + private var graphStatDisplayIndex: Int? = null + private var subConfigException: GraphStatConfigEvent.ValidationException? = null + + override fun initViewModel(graphStatGroupId: Long, graphStatId: Long) { + if (this.graphStatGroupId != -1L) return + this.graphStatGroupId = graphStatGroupId + viewModelScope.launch(io) { + thisIsLoading.value = true + if (graphStatId != -1L) initFromExistingGraphStat(graphStatId) + thisIsLoading.value = false + } + } + + private suspend fun initFromExistingGraphStat(graphStatId: Long) { + val graphStat = withContext(io) { + dataInteractor.tryGetGraphStatById(graphStatId) + } + + withContext(ui) { + if (graphStat == null) { + return@withContext + } + + this@GraphStatInputViewModelImpl.graphName = graphStat.name.asTfv() + this@GraphStatInputViewModelImpl.graphStatType.value = graphStat.type + this@GraphStatInputViewModelImpl.graphStatId = graphStat.id + this@GraphStatInputViewModelImpl.graphStatDisplayIndex = graphStat.displayIndex + this@GraphStatInputViewModelImpl.updateMode.value = true + } + } + + private fun String.asTfv() = TextFieldValue(this, TextRange(this.length)) + + override fun setGraphStatName(name: TextFieldValue) { + if (this.graphName != name) { + this.graphName = name + updateDemoData() + } + } + + override fun setGraphType(type: GraphStatType) { + configData = null + this.graphStatType.value = type + } + + override fun onConfigEvent(configData: GraphStatConfigEvent?) { + configIsLoading.value = false + when (configData) { + is GraphStatConfigEvent.ValidationException -> onValidationException(configData) + is GraphStatConfigEvent.Loading -> configIsLoading.value = true + is GraphStatConfigEvent.ConfigData<*> -> updateConfigData(configData) + null -> {} + } + } + + private fun updateConfigData(configData: GraphStatConfigEvent.ConfigData<*>) { + subConfigException = null + this.configData = configData + updateDemoData() + } + + override fun createGraphOrStat() { + if (thisIsLoading.value) return + configData?.config?.let { + thisIsLoading.value = true + viewModelScope.launch(io) { + gsiProvider + .getDataSourceAdapter(graphStatType.value!!) + .writeConfig(constructGraphOrStat(), it, updateMode.value!!) + withContext(ui) { complete.value = true } + } + } + } + + private fun onValidationException(exception: GraphStatConfigEvent.ValidationException?) { + this.subConfigException = exception + validateConfiguration() + } + + private fun updateDemoData() { + validateConfiguration() + if (validationException.value == null) updateDemoViewData() + else demoViewData.value = null + } + + private fun updateDemoViewData() { + updateJob?.cancel() + updateJob = viewModelScope.launch(worker) { + configData?.config?.let { + val graphOrStat = constructGraphOrStat() + + withContext(ui) { demoViewData.value = IGraphStatViewData.loading(graphOrStat) } + + val demoData = gsiProvider + .getDataFactory(graphOrStat.type) + .getViewData(graphOrStat, it) + + withContext(ui) { demoViewData.value = demoData } + } + } + } + + private fun validateConfiguration() { + val configException = when { + graphName.text.isEmpty() -> GraphStatConfigEvent.ValidationException(R.string.graph_stat_validation_no_name) + graphStatType.value == null -> GraphStatConfigEvent.ValidationException(R.string.graph_stat_validation_unknown) + else -> null + } + validationException.value = configException ?: subConfigException + } + + private fun constructGraphOrStat() = GraphOrStat( + id = graphStatId ?: 0L, + groupId = graphStatGroupId, + name = graphName.text, + type = graphStatType.value!!, + displayIndex = graphStatDisplayIndex ?: 0 + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/behaviour/FilterableFeatureConfigBehaviour.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/behaviour/FilterableFeatureConfigBehaviour.kt new file mode 100644 index 000000000..26a8f2dd8 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/behaviour/FilterableFeatureConfigBehaviour.kt @@ -0,0 +1,145 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.graphstatinput.configviews.behaviour + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.ui.viewmodels.asTextFieldValue +import com.samco.trackandgraph.ui.viewmodels.asValidatedDouble +import kotlinx.coroutines.* +import javax.inject.Inject + +interface FilterableFeatureConfigBehaviour { + val availableLabels: List + val selectedLabels: List + val fromValue: TextFieldValue + val toValue: TextFieldValue + val filterByLabel: Boolean + val filterByRange: Boolean + val loadingLabels: Boolean + + fun updateFromValue(value: TextFieldValue) + fun updateToValue(value: TextFieldValue) + fun updateSelectedLabels(labels: List) + fun updateFilterByLabel(filter: Boolean) + fun updateFilterByRange(filter: Boolean) +} + +class FilterableFeatureConfigBehaviourImpl @Inject constructor() : + FilterableFeatureConfigBehaviour { + + private var featureId: Long? = null + override var availableLabels: List by mutableStateOf(listOf()) + override var selectedLabels: List by mutableStateOf(listOf()) + override var fromValue: TextFieldValue by mutableStateOf(TextFieldValue("0", TextRange(1))) + override var toValue: TextFieldValue by mutableStateOf(TextFieldValue("1", TextRange(1))) + override var filterByLabel: Boolean by mutableStateOf(false) + override var filterByRange: Boolean by mutableStateOf(false) + override var loadingLabels: Boolean by mutableStateOf(false) + + private var labelUpdateJob: Job? = null + + private lateinit var onUpdate: () -> Unit + private lateinit var io: CoroutineDispatcher + private lateinit var ui: CoroutineDispatcher + private lateinit var coroutineScope: CoroutineScope + private lateinit var dataInteractor: DataInteractor + + fun initFilterableFeatureConfigBehaviour( + onUpdate: () -> Unit, + io: CoroutineDispatcher, + ui: CoroutineDispatcher, + coroutineScope: CoroutineScope, + dataInteractor: DataInteractor + ) { + this.onUpdate = onUpdate + this.io = io + this.ui = ui + this.coroutineScope = coroutineScope + this.dataInteractor = dataInteractor + } + + fun onConfigLoaded( + featureId: Long?, + filterByLabel: Boolean?, + filterByRange: Boolean?, + fromValue: Double?, + toValue: Double?, + selectedLabels: List? + ) { + this.filterByLabel = filterByLabel ?: false + this.filterByRange = filterByRange ?: false + fromValue?.asTextFieldValue()?.let { this.fromValue = it } + toValue?.asTextFieldValue()?.let { this.toValue = it } + this.selectedLabels = selectedLabels ?: emptyList() + this.featureId = featureId + getAvailableLabels() + } + + private fun getAvailableLabels() { + featureId?.let { fId -> + labelUpdateJob?.cancel() + labelUpdateJob = coroutineScope.launch(ui) { + loadingLabels = true + val labels = withContext(io) { + dataInteractor.getLabelsForFeatureId(fId) + .filter { it.isNotEmpty() } + .sorted() + } + availableLabels = labels + loadingLabels = false + } + } + } + + fun onFeatureIdUpdated(id: Long) { + if (id == featureId) return + featureId = id + getAvailableLabels() + selectedLabels = emptyList() + onUpdate() + } + + override fun updateFromValue(value: TextFieldValue) { + fromValue = value.asValidatedDouble() + onUpdate() + } + + override fun updateToValue(value: TextFieldValue) { + toValue = value.asValidatedDouble() + onUpdate() + } + + override fun updateSelectedLabels(labels: List) { + selectedLabels = labels + onUpdate() + } + + override fun updateFilterByLabel(filter: Boolean) { + filterByLabel = filter + onUpdate() + } + + override fun updateFilterByRange(filter: Boolean) { + filterByRange = filter + onUpdate() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/behaviour/SingleFeatureConfigBehaviour.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/behaviour/SingleFeatureConfigBehaviour.kt new file mode 100644 index 000000000..dfb3dbbf5 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/behaviour/SingleFeatureConfigBehaviour.kt @@ -0,0 +1,56 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.graphstatinput.configviews.behaviour + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import javax.inject.Inject + +interface SingleFeatureConfigBehaviour { + val featureId: Long? + val featureMap: Map? + fun updateFeatureId(id: Long) +} + +class SingleFeatureConfigBehaviourImpl @Inject constructor() : SingleFeatureConfigBehaviour { + override var featureId: Long? by mutableStateOf(null) + override var featureMap: Map? by mutableStateOf(null) + private set + + lateinit var onUpdate: () -> Unit + lateinit var featureChangeCallback: (Long) -> Unit + + override fun updateFeatureId(id: Long) { + featureId = id + onUpdate() + featureChangeCallback(id) + } + + fun onConfigLoaded(map: Map, featureId: Long) { + this.featureId = featureId + featureMap = map + } + + fun initSingleFeatureConfigBehaviour( + onUpdate: () -> Unit, + featureChangeCallback: (Long) -> Unit = {} + ) { + this.onUpdate = onUpdate + this.featureChangeCallback = featureChangeCallback + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/behaviour/TimeRangeConfigBehaviour.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/behaviour/TimeRangeConfigBehaviour.kt new file mode 100644 index 000000000..230b1826c --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/behaviour/TimeRangeConfigBehaviour.kt @@ -0,0 +1,67 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.graphstatinput.configviews.behaviour + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.samco.trackandgraph.base.database.dto.GraphEndDate +import com.samco.trackandgraph.graphstatinput.customviews.GraphStatSampleSize +import com.samco.trackandgraph.graphstatinput.customviews.SampleEndingAt +import org.threeten.bp.temporal.TemporalAmount +import javax.inject.Inject + +interface EndingAtConfigBehaviour { + val sampleEndingAt: SampleEndingAt + fun updateSampleEndingAt(endingAt: SampleEndingAt) +} + +interface TimeRangeConfigBehaviour : EndingAtConfigBehaviour { + val selectedDuration: GraphStatSampleSize + fun updateDuration(duration: GraphStatSampleSize) +} + +class TimeRangeConfigBehaviourImpl @Inject constructor() : TimeRangeConfigBehaviour { + private lateinit var onUpdate: () -> Unit + + override var selectedDuration by mutableStateOf(GraphStatSampleSize.AllData) + + override var sampleEndingAt by mutableStateOf(SampleEndingAt.Latest) + + fun initTimeRangeConfigBehaviour(onUpdate: () -> Unit) { + this.onUpdate = onUpdate + } + + fun onConfigLoaded( + sampleSize: TemporalAmount?, + endingAt: GraphEndDate? + ) { + sampleSize?.let { selectedDuration = GraphStatSampleSize.fromTemporalAmount(it) } + endingAt?.let { sampleEndingAt = SampleEndingAt.fromGraphEndDate(it) } + } + + override fun updateDuration(duration: GraphStatSampleSize) { + selectedDuration = duration + onUpdate() + } + + override fun updateSampleEndingAt(endingAt: SampleEndingAt) { + sampleEndingAt = endingAt + onUpdate() + } +} + diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/behaviour/YRangeConfigBehaviour.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/behaviour/YRangeConfigBehaviour.kt new file mode 100644 index 000000000..51ad270e4 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/behaviour/YRangeConfigBehaviour.kt @@ -0,0 +1,100 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.samco.trackandgraph.graphstatinput.configviews.behaviour + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import com.samco.trackandgraph.base.database.dto.YRangeType +import com.samco.trackandgraph.ui.viewmodels.DurationInputViewModel +import com.samco.trackandgraph.ui.viewmodels.DurationInputViewModelImpl +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import javax.inject.Inject + +interface YRangeConfigBehaviour { + val yRangeType: YRangeType + val timeBasedRange: Flow + val yRangeFrom: TextFieldValue + val yRangeTo: TextFieldValue + + val yRangeFromDurationViewModel: DurationInputViewModel + val yRangeToDurationViewModel: DurationInputViewModel + + fun updateYRangeType(yRangeType: YRangeType) + fun updateYRangeFrom(yRangeFrom: TextFieldValue) + fun updateYRangeTo(yRangeTo: TextFieldValue) +} + +class YRangeConfigBehaviourImpl @Inject constructor() : YRangeConfigBehaviour { + override var yRangeType by mutableStateOf(YRangeType.DYNAMIC) + private set + override var yRangeFrom by mutableStateOf(TextFieldValue("0.0")) + private set + override var yRangeTo by mutableStateOf(TextFieldValue("1.0")) + private set + + override val yRangeFromDurationViewModel = DurationInputViewModelImpl() + .apply { setOnChangeListener { onUpdate() } } + override val yRangeToDurationViewModel = DurationInputViewModelImpl() + .apply { setOnChangeListener { onUpdate() } } + + private val timeBasedRangeFlow = MutableStateFlow>(emptyFlow()) + override val timeBasedRange: Flow = timeBasedRangeFlow + .flatMapLatest { it } + + private lateinit var onUpdate: () -> Unit + + fun initYRangeConfigBehaviour(onUpdate: () -> Unit) { + this.onUpdate = onUpdate + } + + fun onConfigLoaded( + yRangeType: YRangeType?, + yFrom: Double?, + yTo: Double?, + timeBasedRange: Flow + ) { + this.yRangeType = yRangeType ?: YRangeType.DYNAMIC + this.yRangeFrom = TextFieldValue(yFrom?.toString() ?: "0.0") + this.yRangeTo = TextFieldValue(yTo?.toString() ?: "1.0") + this.yRangeFromDurationViewModel.setDurationFromDouble(yFrom ?: 0.0) + this.yRangeToDurationViewModel.setDurationFromDouble(yTo ?: (60.0 * 60.0)) + this.timeBasedRangeFlow.value = timeBasedRange + } + + override fun updateYRangeType(yRangeType: YRangeType) { + this.yRangeType = yRangeType + onUpdate() + } + + override fun updateYRangeFrom(yRangeFrom: TextFieldValue) { + this.yRangeFrom = yRangeFrom + onUpdate() + } + + override fun updateYRangeTo(yRangeTo: TextFieldValue) { + this.yRangeTo = yRangeTo + onUpdate() + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/ui/AverageTimeBetweenConfigView.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/ui/AverageTimeBetweenConfigView.kt new file mode 100644 index 000000000..63d450974 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/ui/AverageTimeBetweenConfigView.kt @@ -0,0 +1,100 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.graphstatinput.configviews.ui + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModelStoreOwner +import com.samco.trackandgraph.R +import com.samco.trackandgraph.graphstatinput.GraphStatConfigEvent +import com.samco.trackandgraph.graphstatinput.configviews.viewmodel.AverageTimeBetweenConfigViewModel +import com.samco.trackandgraph.graphstatinput.customviews.FilterByLabelSection +import com.samco.trackandgraph.graphstatinput.customviews.FilterByValueSection +import com.samco.trackandgraph.graphstatinput.customviews.GraphStatDurationSpinner +import com.samco.trackandgraph.graphstatinput.customviews.GraphStatEndingAtSpinner +import com.samco.trackandgraph.ui.compose.ui.InputSpacingLarge +import com.samco.trackandgraph.ui.compose.ui.DialogInputSpacing +import com.samco.trackandgraph.ui.compose.ui.TextMapSpinner + +@Composable +fun AverageTimeBetweenConfigView( + scrollState: ScrollState, + viewModelStoreOwner: ViewModelStoreOwner, + graphStatId: Long, + onConfigEvent: (GraphStatConfigEvent?) -> Unit +) { + val viewModel = hiltViewModel(viewModelStoreOwner).apply { + initFromGraphStatId(graphStatId) + } + + LaunchedEffect(viewModel) { + viewModel.getConfigFlow().collect { onConfigEvent(it) } + } + + GraphStatDurationSpinner( + modifier = Modifier, + selectedDuration = viewModel.selectedDuration, + onDurationSelected = { viewModel.updateDuration(it) } + ) + + GraphStatEndingAtSpinner( + modifier = Modifier, + sampleEndingAt = viewModel.sampleEndingAt + ) { viewModel.updateSampleEndingAt(it) } + + DialogInputSpacing() + + Divider() + + InputSpacingLarge() + + Text( + modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.card_padding)), + text = stringResource(id = R.string.select_a_feature), + style = MaterialTheme.typography.subtitle2 + ) + + val featureId = viewModel.featureId + val featureMap = viewModel.featureMap + + if (featureId != null && featureMap != null) { + TextMapSpinner( + strings = featureMap, + selectedItem = featureId, + onItemSelected = { viewModel.updateFeatureId(it) } + ) + } + + InputSpacingLarge() + + FilterByLabelSection(viewModel) + + InputSpacingLarge() + + FilterByValueSection(viewModel) + + DialogInputSpacing() +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/ui/BarChartConfigView.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/ui/BarChartConfigView.kt new file mode 100644 index 000000000..caf9858d6 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/ui/BarChartConfigView.kt @@ -0,0 +1,157 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.graphstatinput.configviews.ui + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModelStoreOwner +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.BarChartBarPeriod +import com.samco.trackandgraph.base.database.dto.YRangeType +import com.samco.trackandgraph.graphstatinput.GraphStatConfigEvent +import com.samco.trackandgraph.graphstatinput.configviews.viewmodel.BarChartConfigViewModel +import com.samco.trackandgraph.graphstatinput.customviews.GraphStatDurationSpinner +import com.samco.trackandgraph.graphstatinput.customviews.GraphStatEndingAtSpinner +import com.samco.trackandgraph.graphstatinput.customviews.GraphStatYRangeTypeSpinner +import com.samco.trackandgraph.graphstatinput.customviews.YRangeFromToInputs +import com.samco.trackandgraph.ui.compose.ui.LabeledRow +import com.samco.trackandgraph.ui.compose.ui.MiniNumericTextField +import com.samco.trackandgraph.ui.compose.ui.RowCheckbox +import com.samco.trackandgraph.ui.compose.ui.DialogInputSpacing +import com.samco.trackandgraph.ui.compose.ui.TextMapSpinner + +@Composable +fun BarChartConfigView( + scrollState: ScrollState, + viewModelStoreOwner: ViewModelStoreOwner, + graphStatId: Long, + onConfigEvent: (GraphStatConfigEvent?) -> Unit +) { + val viewModel = hiltViewModel(viewModelStoreOwner).apply { + initFromGraphStatId(graphStatId) + } + + LaunchedEffect(viewModel) { + viewModel.getConfigFlow().collect { onConfigEvent(it) } + } + + GraphStatDurationSpinner( + modifier = Modifier, + selectedDuration = viewModel.selectedDuration, + onDurationSelected = { viewModel.updateDuration(it) } + ) + + GraphStatEndingAtSpinner( + modifier = Modifier, + sampleEndingAt = viewModel.sampleEndingAt + ) { viewModel.updateSampleEndingAt(it) } + + GraphStatYRangeTypeSpinner( + yRangeType = viewModel.yRangeType, + onYRangeTypeSelected = { viewModel.updateYRangeType(it) } + ) + + if (viewModel.yRangeType == YRangeType.FIXED) { + YRangeFromToInputs(viewModel = viewModel, fromEnabled = false) + } + + DialogInputSpacing() + + Divider() + + DialogInputSpacing() + + Text( + modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.card_padding)), + text = stringResource(id = R.string.select_a_feature), + style = MaterialTheme.typography.subtitle2 + ) + + val featureId = viewModel.featureId + val featureMap = viewModel.featureMap + + if (featureId != null && featureMap != null) { + TextMapSpinner( + strings = featureMap, + selectedItem = featureId, + onItemSelected = { viewModel.updateFeatureId(it) } + ) + } + + val strings = stringArrayResource(id = R.array.time_histogram_windows) + val barIntervalNames = remember { + mapOf( + BarChartBarPeriod.HOUR to strings[0], + BarChartBarPeriod.DAY to strings[1], + BarChartBarPeriod.WEEK to strings[2], + BarChartBarPeriod.MONTH to strings[3], + BarChartBarPeriod.THREE_MONTHS to strings[4], + BarChartBarPeriod.SIX_MONTHS to strings[5], + BarChartBarPeriod.YEAR to strings[6] + ) + } + + LabeledRow( + label = stringResource(id = R.string.bar_interval), + paddingValues = PaddingValues(start = dimensionResource(id = R.dimen.card_padding)) + ) { + TextMapSpinner( + strings = barIntervalNames, + selectedItem = viewModel.selectedBarPeriod, + onItemSelected = viewModel::updateBarPeriod + ) + } + + DialogInputSpacing() + + RowCheckbox( + checked = viewModel.sumByCount, + onCheckedChange = { viewModel.updateSumByCount(it) }, + text = stringResource(id = R.string.sum_by_count_checkbox_label) + ) + + DialogInputSpacing() + + LabeledRow( + label = stringResource(id = R.string.scale), + paddingValues = PaddingValues(start = dimensionResource(id = R.dimen.card_padding)) + ) { + MiniNumericTextField( + modifier = Modifier + .weight(1f) + .alignByBaseline(), + textAlign = TextAlign.Center, + textFieldValue = viewModel.scale, + onValueChange = { viewModel.updateScale(it) } + ) + } + + DialogInputSpacing() +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/ui/LastValueConfigView.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/ui/LastValueConfigView.kt new file mode 100644 index 000000000..e23ec7854 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/ui/LastValueConfigView.kt @@ -0,0 +1,93 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.graphstatinput.configviews.ui + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModelStoreOwner +import com.samco.trackandgraph.R +import com.samco.trackandgraph.graphstatinput.GraphStatConfigEvent +import com.samco.trackandgraph.graphstatinput.configviews.viewmodel.LastValueConfigViewModel +import com.samco.trackandgraph.graphstatinput.customviews.FilterByLabelSection +import com.samco.trackandgraph.graphstatinput.customviews.FilterByValueSection +import com.samco.trackandgraph.graphstatinput.customviews.GraphStatEndingAtSpinner +import com.samco.trackandgraph.ui.compose.ui.InputSpacingLarge +import com.samco.trackandgraph.ui.compose.ui.DialogInputSpacing +import com.samco.trackandgraph.ui.compose.ui.TextMapSpinner + +@Composable +fun LastValueConfigView( + scrollState: ScrollState, + viewModelStoreOwner: ViewModelStoreOwner, + graphStatId: Long, + onConfigEvent: (GraphStatConfigEvent?) -> Unit +) { + val viewModel = hiltViewModel(viewModelStoreOwner).apply { + initFromGraphStatId(graphStatId) + } + + LaunchedEffect(viewModel) { + viewModel.getConfigFlow().collect { onConfigEvent(it) } + } + + GraphStatEndingAtSpinner( + modifier = Modifier, + sampleEndingAt = viewModel.sampleEndingAt + ) { viewModel.updateSampleEndingAt(it) } + + DialogInputSpacing() + + Divider() + + InputSpacingLarge() + + Text( + modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.card_padding)), + text = stringResource(id = R.string.select_a_feature), + style = MaterialTheme.typography.subtitle2 + ) + + val featureId = viewModel.featureId + val featureMap = viewModel.featureMap + + if (featureId != null && featureMap != null) { + TextMapSpinner( + strings = featureMap, + selectedItem = featureId, + onItemSelected = { viewModel.updateFeatureId(it) } + ) + } + + InputSpacingLarge() + + FilterByLabelSection(viewModel) + + InputSpacingLarge() + + FilterByValueSection(viewModel) + + DialogInputSpacing() +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/ui/LineGraphConfigView.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/ui/LineGraphConfigView.kt new file mode 100644 index 000000000..40416cd88 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/ui/LineGraphConfigView.kt @@ -0,0 +1,272 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +@file:OptIn(ExperimentalComposeUiApi::class) + +package com.samco.trackandgraph.graphstatinput.configviews.ui + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.* +import androidx.compose.ui.text.style.TextAlign +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModelStoreOwner +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.* +import com.samco.trackandgraph.graphstatinput.GraphStatConfigEvent +import com.samco.trackandgraph.graphstatinput.configviews.viewmodel.LineGraphConfigViewModel +import com.samco.trackandgraph.graphstatinput.customviews.GraphStatDurationSpinner +import com.samco.trackandgraph.graphstatinput.customviews.GraphStatEndingAtSpinner +import com.samco.trackandgraph.graphstatinput.customviews.GraphStatYRangeTypeSpinner +import com.samco.trackandgraph.graphstatinput.customviews.YRangeFromToInputs +import com.samco.trackandgraph.ui.compose.ui.* +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun LineGraphConfigView( + scrollState: ScrollState, + viewModelStoreOwner: ViewModelStoreOwner, + graphStatId: Long, + onConfigEvent: (GraphStatConfigEvent?) -> Unit +) { + val viewModel = hiltViewModel(viewModelStoreOwner).apply { + initFromGraphStatId(graphStatId) + } + + LaunchedEffect(viewModel) { + viewModel.getConfigFlow().collect { onConfigEvent(it) } + } + + GraphStatDurationSpinner( + modifier = Modifier, + selectedDuration = viewModel.selectedDuration, + onDurationSelected = { viewModel.updateDuration(it) } + ) + + GraphStatEndingAtSpinner( + modifier = Modifier, + sampleEndingAt = viewModel.sampleEndingAt + ) { viewModel.updateSampleEndingAt(it) } + + GraphStatYRangeTypeSpinner( + yRangeType = viewModel.yRangeType, + onYRangeTypeSelected = { viewModel.updateYRangeType(it) } + ) + + if (viewModel.yRangeType == YRangeType.FIXED) YRangeFromToInputs(viewModel) + + DialogInputSpacing() + + Divider() + + DialogInputSpacing() + + LineGraphFeaturesInputView(scrollState, viewModel) + + DialogInputSpacing() +} + +@Composable +private fun LineGraphFeaturesInputView( + scrollState: ScrollState, + viewModel: LineGraphConfigViewModel +) = Column( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(), + horizontalAlignment = Alignment.CenterHorizontally +) { + + for (index in viewModel.lineGraphFeatures.indices) { + val lgf = viewModel.lineGraphFeatures[index] + LineGraphFeatureInputView( + lgf = lgf, + features = viewModel.featurePaths, + textFields = viewModel.getTextFieldsFor(index), + onRemove = { viewModel.removeLineGraphFeature(index) }, + onUpdate = { viewModel.updateLineGraphFeature(index, it) } + ) + DialogInputSpacing() + } + + val coroutineScope = rememberCoroutineScope() + + AddBarButton( + onClick = { + viewModel.onAddLineGraphFeatureClicked() + coroutineScope.launch { + delay(200) + scrollState.animateScrollTo(scrollState.maxValue) + } + }, + ) +} + +@Composable +private fun LineGraphFeatureInputView( + lgf: LineGraphFeature, + features: List, + textFields: LineGraphConfigViewModel.FeatureTextFields, + onRemove: () -> Unit, + onUpdate: (LineGraphFeature) -> Unit +) = Card { + Column( + modifier = Modifier.padding(dimensionResource(id = R.dimen.card_padding)), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + FullWidthTextField( + modifier = Modifier.weight(1f), + textFieldValue = textFields.name, + onValueChange = { textFields.updateName(it) } + ) + IconButton(onClick = { onRemove() }) { + Icon( + painter = painterResource(id = R.drawable.delete_icon), + contentDescription = stringResource(id = R.string.delete_line_button_content_description) + ) + } + } + HalfDialogInputSpacing() + Row(modifier = Modifier.height(IntrinsicSize.Max)) { + Column( + modifier = Modifier.fillMaxHeight(), + verticalArrangement = Arrangement.SpaceEvenly + ) { + ColorSpinner( + selectedColor = lgf.colorIndex, + onColorSelected = { onUpdate(lgf.copy(colorIndex = it)) } + ) + + val pointStyleIcons = listOf( + R.drawable.point_style_none_icon, + R.drawable.point_style_circles_icon, + R.drawable.point_style_circles_and_numbers_icon + ) + + CircleSpinner( + numItems = pointStyleIcons.size, + selectedIndex = LineGraphPointStyle.values().indexOf(lgf.pointStyle), + onIndexSelected = { onUpdate(lgf.copy(pointStyle = LineGraphPointStyle.values()[it])) } + ) { + Icon( + painter = painterResource(id = pointStyleIcons[it]), + contentDescription = null + ) + } + } + Column { + val hoursStr = stringResource(id = R.string.hours) + val minutesStr = stringResource(id = R.string.minutes) + val secondsStr = stringResource(id = R.string.seconds) + + val featureNames = remember(features) { + features.associateBy( + keySelector = { it.id }, + valueTransform = { + when (it.id.durationPlottingMode) { + DurationPlottingMode.NONE, DurationPlottingMode.DURATION_IF_POSSIBLE -> it.path + DurationPlottingMode.HOURS -> "${it.path} ($hoursStr)" + DurationPlottingMode.MINUTES -> "${it.path} ($minutesStr)" + DurationPlottingMode.SECONDS -> "${it.path} ($secondsStr)" + } + } + ) + } + + val selectedItem = remember(lgf) { + LineGraphConfigViewModel.FeatureSelectionIdentifier( + featureId = lgf.featureId, + durationPlottingMode = lgf.durationPlottingMode + ) + } + + TextMapSpinner( + strings = featureNames, + selectedItem = selectedItem, + onItemSelected = { + onUpdate( + lgf.copy( + featureId = it.featureId, + durationPlottingMode = it.durationPlottingMode + ) + ) + } + ) + + val averagingModeNames = + stringArrayResource(id = R.array.line_graph_averaging_mode_names) + .mapIndexed { index, name -> index to name } + .associate { (index, name) -> LineGraphAveraginModes.values()[index] to name } + + TextMapSpinner( + strings = averagingModeNames, + selectedItem = lgf.averagingMode, + onItemSelected = { onUpdate(lgf.copy(averagingMode = it)) } + ) + + val plotModeNames = stringArrayResource(id = R.array.line_graph_plot_mode_names) + .mapIndexed { index, name -> index to name } + .associate { (index, name) -> LineGraphPlottingModes.values()[index] to name } + + TextMapSpinner( + strings = plotModeNames, + selectedItem = lgf.plottingMode, + onItemSelected = { onUpdate(lgf.copy(plottingMode = it)) } + ) + Row { + Text( + modifier = Modifier.alignByBaseline(), + text = stringResource(id = R.string.offset), + style = MaterialTheme.typography.body1 + ) + + MiniNumericTextField( + modifier = Modifier + .weight(1f) + .alignByBaseline(), + textAlign = TextAlign.Center, + textFieldValue = textFields.offset, + onValueChange = { textFields.updateOffset(it) } + ) + + DialogInputSpacing() + + Text( + modifier = Modifier.alignByBaseline(), + text = stringResource(id = R.string.scale), + style = MaterialTheme.typography.body1 + ) + + MiniNumericTextField( + modifier = Modifier + .weight(1f) + .alignByBaseline(), + textAlign = TextAlign.Center, + textFieldValue = textFields.scale, + onValueChange = { textFields.updateScale(it) } + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/ui/PieChartConfigView.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/ui/PieChartConfigView.kt new file mode 100644 index 000000000..776b44319 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/ui/PieChartConfigView.kt @@ -0,0 +1,101 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.graphstatinput.configviews.ui + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModelStoreOwner +import com.samco.trackandgraph.R +import com.samco.trackandgraph.graphstatinput.GraphStatConfigEvent +import com.samco.trackandgraph.graphstatinput.configviews.viewmodel.PieChartConfigViewModel +import com.samco.trackandgraph.graphstatinput.customviews.GraphStatDurationSpinner +import com.samco.trackandgraph.graphstatinput.customviews.GraphStatEndingAtSpinner +import com.samco.trackandgraph.ui.compose.ui.RowCheckbox +import com.samco.trackandgraph.ui.compose.ui.InputSpacingLarge +import com.samco.trackandgraph.ui.compose.ui.DialogInputSpacing +import com.samco.trackandgraph.ui.compose.ui.TextMapSpinner + +@Composable +fun PieChartConfigView( + scrollState: ScrollState, + viewModelStoreOwner: ViewModelStoreOwner, + graphStatId: Long, + onConfigEvent: (GraphStatConfigEvent?) -> Unit +) = Column { + val viewModel = hiltViewModel(viewModelStoreOwner).apply { + initFromGraphStatId(graphStatId) + } + + LaunchedEffect(viewModel) { + viewModel.getConfigFlow().collect { onConfigEvent(it) } + } + + + GraphStatDurationSpinner( + modifier = Modifier, + selectedDuration = viewModel.selectedDuration, + onDurationSelected = { viewModel.updateDuration(it) } + ) + + GraphStatEndingAtSpinner( + modifier = Modifier, + sampleEndingAt = viewModel.sampleEndingAt + ) { viewModel.updateSampleEndingAt(it) } + + DialogInputSpacing() + + Divider() + + InputSpacingLarge() + + Text( + modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.card_padding)), + text = stringResource(id = R.string.select_a_discrete_feature), + style = MaterialTheme.typography.subtitle2 + ) + + val featureId = viewModel.featureId + val featureMap = viewModel.featureMap + + if (featureId != null && featureMap != null) { + TextMapSpinner( + strings = featureMap, + selectedItem = featureId, + onItemSelected = { viewModel.updateFeatureId(it) } + ) + } + + DialogInputSpacing() + + RowCheckbox( + checked = viewModel.sumByCount, + onCheckedChange = { viewModel.updateSumByCount(it) }, + text = stringResource(id = R.string.sum_by_count_checkbox_label) + ) + + DialogInputSpacing() +} diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/ui/TimeHistogramConfigView.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/ui/TimeHistogramConfigView.kt new file mode 100644 index 000000000..c61ffd2fe --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/ui/TimeHistogramConfigView.kt @@ -0,0 +1,124 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.graphstatinput.configviews.ui + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModelStoreOwner +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.TimeHistogramWindow +import com.samco.trackandgraph.graphstatinput.GraphStatConfigEvent +import com.samco.trackandgraph.graphstatinput.configviews.viewmodel.TimeHistogramConfigViewModel +import com.samco.trackandgraph.graphstatinput.customviews.GraphStatDurationSpinner +import com.samco.trackandgraph.graphstatinput.customviews.GraphStatEndingAtSpinner +import com.samco.trackandgraph.ui.compose.ui.* + +@Composable +fun TimeHistogramConfigView( + scrollState: ScrollState, + viewModelStoreOwner: ViewModelStoreOwner, + graphStatId: Long, + onConfigEvent: (GraphStatConfigEvent?) -> Unit +) = Column { + val viewModel = hiltViewModel(viewModelStoreOwner).apply { + initFromGraphStatId(graphStatId) + } + + LaunchedEffect(viewModel) { + viewModel.getConfigFlow().collect { onConfigEvent(it) } + } + + GraphStatDurationSpinner( + modifier = Modifier, + selectedDuration = viewModel.selectedDuration, + onDurationSelected = { viewModel.updateDuration(it) } + ) + + GraphStatEndingAtSpinner( + modifier = Modifier, + sampleEndingAt = viewModel.sampleEndingAt + ) { viewModel.updateSampleEndingAt(it) } + + DialogInputSpacing() + + Divider() + + InputSpacingLarge() + + Text( + modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.card_padding)), + text = stringResource(id = R.string.select_a_feature), + style = MaterialTheme.typography.subtitle2 + ) + + val featureId = viewModel.featureId + val featureMap = viewModel.featureMap + + if (featureId != null && featureMap != null) { + TextMapSpinner( + strings = featureMap, + selectedItem = featureId, + onItemSelected = { viewModel.updateFeatureId(it) } + ) + } + + DialogInputSpacing() + + LabeledRow( + label = stringResource(id = R.string.time_window_size), + paddingValues = PaddingValues(start = dimensionResource(id = R.dimen.card_padding)) + ) { + val stringArray = stringArrayResource(id = R.array.time_histogram_windows) + val timeWindows = mapOf( + TimeHistogramWindow.HOUR to stringArray[0], + TimeHistogramWindow.DAY to stringArray[1], + TimeHistogramWindow.WEEK to stringArray[2], + TimeHistogramWindow.MONTH to stringArray[3], + TimeHistogramWindow.THREE_MONTHS to stringArray[4], + TimeHistogramWindow.SIX_MONTHS to stringArray[5], + TimeHistogramWindow.YEAR to stringArray[6] + ) + + TextMapSpinner( + strings = timeWindows, + selectedItem = viewModel.selectedWindow, + onItemSelected = { viewModel.updateWindow(it) } + ) + } + + DialogInputSpacing() + + RowCheckbox( + checked = viewModel.sumByCount, + onCheckedChange = { viewModel.updateSumByCount(it) }, + text = stringResource(id = R.string.sum_by_count_checkbox_label) + ) + + DialogInputSpacing() +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/viewmodel/AverageTimeBetweenConfigViewModel.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/viewmodel/AverageTimeBetweenConfigViewModel.kt new file mode 100644 index 000000000..9389ef011 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/viewmodel/AverageTimeBetweenConfigViewModel.kt @@ -0,0 +1,135 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.graphstatinput.configviews.viewmodel + +import androidx.lifecycle.viewModelScope +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.AverageTimeBetweenStat +import com.samco.trackandgraph.base.database.dto.GraphEndDate +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.DefaultDispatcher +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.base.model.di.MainDispatcher +import com.samco.trackandgraph.graphstatinput.GraphStatConfigEvent +import com.samco.trackandgraph.graphstatinput.configviews.behaviour.* +import com.samco.trackandgraph.graphstatproviders.GraphStatInteractorProvider +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.* +import javax.inject.Inject + +@HiltViewModel +class AverageTimeBetweenConfigViewModel @Inject constructor( + @IODispatcher private val io: CoroutineDispatcher, + @DefaultDispatcher private val default: CoroutineDispatcher, + @MainDispatcher private val ui: CoroutineDispatcher, + gsiProvider: GraphStatInteractorProvider, + dataInteractor: DataInteractor, + private val timeRangeConfigBehaviour: TimeRangeConfigBehaviourImpl = TimeRangeConfigBehaviourImpl(), + private val filterableFeatureConfigBehaviour: FilterableFeatureConfigBehaviourImpl = FilterableFeatureConfigBehaviourImpl(), + private val singleFeatureConfigBehaviour: SingleFeatureConfigBehaviourImpl = SingleFeatureConfigBehaviourImpl(), +) : GraphStatConfigViewModelBase( + io, + default, + ui, + gsiProvider, + dataInteractor +), TimeRangeConfigBehaviour by timeRangeConfigBehaviour, + FilterableFeatureConfigBehaviour by filterableFeatureConfigBehaviour, + SingleFeatureConfigBehaviour by singleFeatureConfigBehaviour { + + init { + timeRangeConfigBehaviour.initTimeRangeConfigBehaviour { onUpdate() } + filterableFeatureConfigBehaviour.initFilterableFeatureConfigBehaviour( + onUpdate = { onUpdate() }, + io = io, + ui = ui, + coroutineScope = viewModelScope, + dataInteractor = dataInteractor + ) + singleFeatureConfigBehaviour.initSingleFeatureConfigBehaviour( + onUpdate = { onUpdate() }, + featureChangeCallback = { filterableFeatureConfigBehaviour.onFeatureIdUpdated(it) }, + ) + } + + private var averageTimeBetweenStat: AverageTimeBetweenStat = AverageTimeBetweenStat( + id = 0, + graphStatId = 0, + featureId = -1L, + fromValue = 0.0, + toValue = 1.0, + sampleSize = null, + labels = listOf(), + endDate = GraphEndDate.Latest, + filterByRange = false, + filterByLabels = false + ) + + override fun updateConfig() { + averageTimeBetweenStat = averageTimeBetweenStat.copy( + featureId = featureId ?: -1L, + fromValue = fromValue.text.toDoubleOrNull() ?: 0.0, + toValue = toValue.text.toDoubleOrNull() ?: 1.0, + sampleSize = selectedDuration.temporalAmount, + labels = selectedLabels, + endDate = sampleEndingAt.asGraphEndDate(), + filterByRange = filterByRange, + filterByLabels = filterByLabel + ) + } + + override fun getConfig(): GraphStatConfigEvent.ConfigData.AverageTimeBetweenConfigData { + return GraphStatConfigEvent.ConfigData.AverageTimeBetweenConfigData(averageTimeBetweenStat) + } + + override suspend fun validate(): GraphStatConfigEvent.ValidationException? { + if (averageTimeBetweenStat.featureId == -1L) + return GraphStatConfigEvent.ValidationException(R.string.graph_stat_validation_no_line_graph_features) + if (averageTimeBetweenStat.filterByRange && (averageTimeBetweenStat.fromValue > averageTimeBetweenStat.toValue)) + return GraphStatConfigEvent.ValidationException(R.string.graph_stat_validation_invalid_value_stat_from_to) + return null + } + + override fun onDataLoaded(config: Any?) { + + val avStat = config as? AverageTimeBetweenStat + + val featureMap = featurePathProvider.sortedFeatureMap() + val featureId = avStat?.featureId ?: featureMap.keys.first() + + singleFeatureConfigBehaviour.onConfigLoaded( + map = featureMap, + featureId = featureId + ) + + filterableFeatureConfigBehaviour.onConfigLoaded( + featureId = featureId, + filterByLabel = avStat?.filterByLabels, + filterByRange = avStat?.filterByRange, + fromValue = avStat?.fromValue, + toValue = avStat?.toValue, + selectedLabels = avStat?.labels + ) + + timeRangeConfigBehaviour.onConfigLoaded( + sampleSize = avStat?.sampleSize, + endingAt = avStat?.endDate + ) + + avStat?.let { this.averageTimeBetweenStat = it } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/viewmodel/BarChartConfigViewModel.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/viewmodel/BarChartConfigViewModel.kt new file mode 100644 index 000000000..b705427af --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/viewmodel/BarChartConfigViewModel.kt @@ -0,0 +1,177 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.graphstatinput.configviews.viewmodel + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.viewModelScope +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.BarChart +import com.samco.trackandgraph.base.database.dto.BarChartBarPeriod +import com.samco.trackandgraph.base.database.dto.GraphEndDate +import com.samco.trackandgraph.base.database.dto.YRangeType +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.DefaultDispatcher +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.base.model.di.MainDispatcher +import com.samco.trackandgraph.graphstatinput.GraphStatConfigEvent +import com.samco.trackandgraph.graphstatinput.configviews.behaviour.* +import com.samco.trackandgraph.graphstatproviders.GraphStatInteractorProvider +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class BarChartConfigViewModel @Inject constructor( + @IODispatcher private val io: CoroutineDispatcher, + @DefaultDispatcher private val default: CoroutineDispatcher, + @MainDispatcher private val ui: CoroutineDispatcher, + gsiProvider: GraphStatInteractorProvider, + dataInteractor: DataInteractor, + private val timeRangeConfigBehaviour: TimeRangeConfigBehaviourImpl = TimeRangeConfigBehaviourImpl(), + private val singleFeatureConfigBehaviour: SingleFeatureConfigBehaviourImpl = SingleFeatureConfigBehaviourImpl(), + private val yRangeConfigBehaviour: YRangeConfigBehaviourImpl = YRangeConfigBehaviourImpl(), +) : GraphStatConfigViewModelBase( + io, + default, + ui, + gsiProvider, + dataInteractor +), TimeRangeConfigBehaviour by timeRangeConfigBehaviour, + SingleFeatureConfigBehaviour by singleFeatureConfigBehaviour, + YRangeConfigBehaviour by yRangeConfigBehaviour { + + init { + timeRangeConfigBehaviour.initTimeRangeConfigBehaviour { onUpdate() } + singleFeatureConfigBehaviour.initSingleFeatureConfigBehaviour(onUpdate = { onUpdate() }) + yRangeConfigBehaviour.initYRangeConfigBehaviour(onUpdate = { onUpdate() }) + } + + private val isTimeBasedRange = snapshotFlow { featureId } + .filterNotNull() + .map { featurePathProvider.getDataSampleProperties(it)?.isDuration == true } + .stateIn(viewModelScope, SharingStarted.Lazily, false) + + var selectedBarPeriod: BarChartBarPeriod by mutableStateOf(BarChartBarPeriod.WEEK) + private set + + var sumByCount: Boolean by mutableStateOf(false) + private set + + var scale: TextFieldValue by mutableStateOf(TextFieldValue("1.0")) + private set + + fun updateScale(value: TextFieldValue) { + scale = value + onUpdate() + } + + fun updateSumByCount(value: Boolean) { + sumByCount = value + onUpdate() + } + + fun updateBarPeriod(barChartBarPeriod: BarChartBarPeriod) { + selectedBarPeriod = barChartBarPeriod + onUpdate() + } + + private var barChart = BarChart( + id = 0, + graphStatId = 0, + featureId = 0, + endDate = GraphEndDate.Latest, + sampleSize = null, + yRangeType = YRangeType.DYNAMIC, + yTo = 1.0, + scale = 1.0, + barPeriod = BarChartBarPeriod.WEEK, + sumByCount = false + ) + + override fun updateConfig() { + barChart = barChart.copy( + featureId = this.featureId ?: -1, + endDate = sampleEndingAt.asGraphEndDate(), + sampleSize = selectedDuration.temporalAmount, + yRangeType = yRangeType, + yTo = getYTo(), + scale = scale.text.toDoubleOrNull() ?: 1.0, + barPeriod = selectedBarPeriod, + sumByCount = sumByCount + ) + } + + private fun getYTo(): Double = + if (isTimeBasedRange.value) yRangeToDurationViewModel.getDurationAsDouble() + else yRangeTo.text.toDoubleOrNull() ?: 1.0 + + override fun getConfig(): GraphStatConfigEvent.ConfigData.BarChartConfigData { + return GraphStatConfigEvent.ConfigData.BarChartConfigData(barChart) + } + + override suspend fun validate(): GraphStatConfigEvent.ValidationException? { + return if (singleFeatureConfigBehaviour.featureId == -1L) { + GraphStatConfigEvent.ValidationException(R.string.graph_stat_validation_no_line_graph_features) + } else null + } + + override fun onDataLoaded(config: Any?) { + val bcConfig = config as? BarChart + + val featureMap = featurePathProvider.sortedFeatureMap() + + timeRangeConfigBehaviour.onConfigLoaded( + sampleSize = bcConfig?.sampleSize, + endingAt = bcConfig?.endDate + ) + + singleFeatureConfigBehaviour.onConfigLoaded( + map = featureMap, + featureId = bcConfig?.featureId ?: featureMap.keys.first() + ) + + yRangeConfigBehaviour.onConfigLoaded( + yRangeType = bcConfig?.yRangeType, + yFrom = 0.0, + yTo = bcConfig?.yTo, + timeBasedRange = isTimeBasedRange + ) + + bcConfig?.let { + barChart = it + selectedBarPeriod = it.barPeriod + sumByCount = it.sumByCount + scale = TextFieldValue(it.scale.toString()) + } + + viewModelScope.launch { + isTimeBasedRange.drop(1).collect { + onUpdate() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/viewmodel/GraphStatConfigViewModelBase.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/viewmodel/GraphStatConfigViewModelBase.kt new file mode 100644 index 000000000..b330d1b18 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/viewmodel/GraphStatConfigViewModelBase.kt @@ -0,0 +1,129 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.graphstatinput.configviews.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.graphstatinput.GraphStatConfigEvent +import com.samco.trackandgraph.graphstatproviders.GraphStatInteractorProvider +import com.samco.trackandgraph.util.FeatureDataProvider +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +abstract class GraphStatConfigViewModelBase>( + private val io: CoroutineDispatcher, + private val default: CoroutineDispatcher, + private val ui: CoroutineDispatcher, + private val gsiProvider: GraphStatInteractorProvider, + protected val dataInteractor: DataInteractor +) : ViewModel() { + + private var configFlow = MutableStateFlow(GraphStatConfigEvent.Loading) + + private var initJob: Job? = null + + private var updateJob: Job? = null + + private var graphStatId: Long? = null + + //This will be available after onDataLoaded is called + protected lateinit var featurePathProvider: FeatureDataProvider + private set + + /** + * You must call this before using the view model even if the intention is to create a new graph + * or stat. This will load the config data from the database and set the [featurePathProvider]. + * If creating a new graph or stat then you can pass in -1L as the graphStatId. + */ + fun initFromGraphStatId(graphStatId: Long) { + if (this.graphStatId == graphStatId) return + this.graphStatId = graphStatId + + initJob = viewModelScope.launch(io) { + configFlow.emit(GraphStatConfigEvent.Loading) + loadFeaturePathProvider() + loadGraphStat(graphStatId) + } + viewModelScope.launch(ui) { onUpdate() } + } + + private suspend fun loadFeaturePathProvider() { + val allFeatures = dataInteractor.getAllFeaturesSync().map { + FeatureDataProvider.DataSourceData( + it, + dataInteractor.getDataSamplePropertiesForFeatureId(it.featureId) + ) + } + val allGroups = dataInteractor.getAllGroupsSync() + featurePathProvider = FeatureDataProvider( + allFeatures, + allGroups + ) + } + + private suspend fun loadGraphStat(graphStatId: Long) { + val configData = dataInteractor.tryGetGraphStatById(graphStatId)?.let { + gsiProvider + .getDataSourceAdapter(it.type) + .getConfigData(graphStatId) + ?.second + } + withContext(ui) { onDataLoaded(configData) } + } + + /** + * Call this to let [GraphStatInputViewModel] know when the demo data or validation exception + * may have changed and need updating + */ + protected fun onUpdate() { + updateJob?.cancel() + updateJob = viewModelScope.launch(default) { + withContext(io) { initJob?.join() } + configFlow.emit(GraphStatConfigEvent.Loading) + updateConfig() + configFlow.emit(validate() ?: getConfig()) + } + } + + /** + * Should update the config data for the graph or stat. This will be validated when [validate] + * is called and returned when [getConfig] is called. + */ + abstract fun updateConfig() + + /** + * Should return the current config data for the graph or stat + */ + abstract fun getConfig(): T + + /** + * Should return a validation exception if the config is invalid or null if it is valid + */ + abstract suspend fun validate(): GraphStatConfigEvent.ValidationException? + + /** + * A flow of events to be relayed to the [GraphStatInputViewModel] + */ + fun getConfigFlow(): Flow = configFlow + + /** + * Called when the config data has been loaded from the database and [featurePathProvider] is + * available. + */ + abstract fun onDataLoaded(config: Any?) +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/viewmodel/LastValueConfigViewModel.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/viewmodel/LastValueConfigViewModel.kt new file mode 100644 index 000000000..449495682 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/viewmodel/LastValueConfigViewModel.kt @@ -0,0 +1,136 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.graphstatinput.configviews.viewmodel + +import androidx.lifecycle.viewModelScope +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.GraphEndDate +import com.samco.trackandgraph.base.database.dto.LastValueStat +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.DefaultDispatcher +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.base.model.di.MainDispatcher +import com.samco.trackandgraph.graphstatinput.GraphStatConfigEvent +import com.samco.trackandgraph.graphstatinput.configviews.behaviour.* +import com.samco.trackandgraph.graphstatproviders.GraphStatInteractorProvider +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +@HiltViewModel +class LastValueConfigViewModel @Inject constructor( + @IODispatcher private val io: CoroutineDispatcher, + @DefaultDispatcher private val default: CoroutineDispatcher, + @MainDispatcher private val ui: CoroutineDispatcher, + gsiProvider: GraphStatInteractorProvider, + dataInteractor: DataInteractor, + private val endingAtConfigBehaviour: TimeRangeConfigBehaviourImpl = TimeRangeConfigBehaviourImpl(), + private val singleFeatureConfigBehaviour: SingleFeatureConfigBehaviourImpl = SingleFeatureConfigBehaviourImpl(), + private val filterableFeatureConfigBehaviour: FilterableFeatureConfigBehaviourImpl = FilterableFeatureConfigBehaviourImpl() +) : GraphStatConfigViewModelBase( + io, + default, + ui, + gsiProvider, + dataInteractor +), + EndingAtConfigBehaviour by endingAtConfigBehaviour, + FilterableFeatureConfigBehaviour by filterableFeatureConfigBehaviour, + SingleFeatureConfigBehaviour by singleFeatureConfigBehaviour { + + init { + endingAtConfigBehaviour.initTimeRangeConfigBehaviour { onUpdate() } + filterableFeatureConfigBehaviour.initFilterableFeatureConfigBehaviour( + onUpdate = { onUpdate() }, + io = io, + ui = ui, + coroutineScope = viewModelScope, + dataInteractor = dataInteractor + ) + singleFeatureConfigBehaviour.initSingleFeatureConfigBehaviour( + onUpdate = { onUpdate() }, + featureChangeCallback = { + filterableFeatureConfigBehaviour.onFeatureIdUpdated(it) + } + ) + } + + private var lastValueStat = LastValueStat( + id = 0, + graphStatId = 0, + featureId = -1L, + endDate = GraphEndDate.Latest, + fromValue = 0.0, + toValue = 0.0, + labels = emptyList(), + filterByRange = false, + filterByLabels = false + ) + + override fun updateConfig() { + lastValueStat = lastValueStat.copy( + featureId = singleFeatureConfigBehaviour.featureId ?: -1L, + endDate = endingAtConfigBehaviour.sampleEndingAt.asGraphEndDate(), + fromValue = filterableFeatureConfigBehaviour.fromValue.text.toDoubleOrNull() ?: 0.0, + toValue = filterableFeatureConfigBehaviour.toValue.text.toDoubleOrNull() ?: 1.0, + labels = filterableFeatureConfigBehaviour.selectedLabels, + filterByRange = filterableFeatureConfigBehaviour.filterByRange, + filterByLabels = filterableFeatureConfigBehaviour.filterByLabel + ) + } + + override fun getConfig(): GraphStatConfigEvent.ConfigData.LastValueConfigData { + return GraphStatConfigEvent.ConfigData.LastValueConfigData(lastValueStat) + } + + override suspend fun validate(): GraphStatConfigEvent.ValidationException? { + if (singleFeatureConfigBehaviour.featureId == -1L) + return GraphStatConfigEvent.ValidationException(R.string.graph_stat_validation_no_line_graph_features) + if (lastValueStat.filterByRange && (lastValueStat.fromValue > lastValueStat.toValue)) + return GraphStatConfigEvent.ValidationException(R.string.graph_stat_validation_invalid_value_stat_from_to) + return null + } + + override fun onDataLoaded(config: Any?) { + + val lvStat = config as? LastValueStat + + val featureMap = featurePathProvider.sortedFeatureMap() + val featureId = lvStat?.featureId ?: featureMap.keys.first() + + singleFeatureConfigBehaviour.onConfigLoaded( + map = featureMap, + featureId = featureId + ) + + filterableFeatureConfigBehaviour.onConfigLoaded( + featureId = featureId, + filterByLabel = lvStat?.filterByLabels, + filterByRange = lvStat?.filterByRange, + fromValue = lvStat?.fromValue, + toValue = lvStat?.toValue, + selectedLabels = lvStat?.labels + ) + + endingAtConfigBehaviour.onConfigLoaded( + sampleSize = null, + endingAt = lvStat?.endDate + ) + + lvStat?.let { this.lastValueStat = it } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/viewmodel/LineGraphConfigViewModel.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/viewmodel/LineGraphConfigViewModel.kt new file mode 100644 index 000000000..ee83d294a --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/viewmodel/LineGraphConfigViewModel.kt @@ -0,0 +1,336 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.graphstatinput.configviews.viewmodel + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.viewModelScope +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.* +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.DefaultDispatcher +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.base.model.di.MainDispatcher +import com.samco.trackandgraph.graphstatinput.GraphStatConfigEvent +import com.samco.trackandgraph.graphstatinput.GraphStatConfigEvent.ValidationException +import com.samco.trackandgraph.graphstatinput.configviews.behaviour.TimeRangeConfigBehaviour +import com.samco.trackandgraph.graphstatinput.configviews.behaviour.TimeRangeConfigBehaviourImpl +import com.samco.trackandgraph.graphstatinput.configviews.behaviour.YRangeConfigBehaviour +import com.samco.trackandgraph.graphstatinput.configviews.behaviour.YRangeConfigBehaviourImpl +import com.samco.trackandgraph.graphstatproviders.GraphStatInteractorProvider +import com.samco.trackandgraph.ui.dataVisColorGenerator +import com.samco.trackandgraph.ui.dataVisColorList +import com.samco.trackandgraph.ui.viewmodels.asValidatedDouble +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class LineGraphConfigViewModel @Inject constructor( + @IODispatcher private val io: CoroutineDispatcher, + @DefaultDispatcher private val default: CoroutineDispatcher, + @MainDispatcher private val ui: CoroutineDispatcher, + gsiProvider: GraphStatInteractorProvider, + dataInteractor: DataInteractor, + private val timeRangeConfigBehaviour: TimeRangeConfigBehaviourImpl = TimeRangeConfigBehaviourImpl(), + private val yRangeConfigBehaviour: YRangeConfigBehaviourImpl = YRangeConfigBehaviourImpl() +) : GraphStatConfigViewModelBase( + io, + default, + ui, + gsiProvider, + dataInteractor +), TimeRangeConfigBehaviour by timeRangeConfigBehaviour, + YRangeConfigBehaviour by yRangeConfigBehaviour { + + init { + timeRangeConfigBehaviour.initTimeRangeConfigBehaviour { onUpdate() } + yRangeConfigBehaviour.initYRangeConfigBehaviour { onUpdate() } + } + + inner class FeatureTextFields( + name: String, + offset: String = "0", + scale: String = "1" + ) { + var name by mutableStateOf(TextFieldValue(name, TextRange(name.length))) + private set + var offset by mutableStateOf(TextFieldValue(offset, TextRange(offset.length))) + private set + var scale by mutableStateOf(TextFieldValue(scale, TextRange(scale.length))) + private set + + fun updateName(name: TextFieldValue, triggerUpdate: Boolean = true) { + this.name = name + if (triggerUpdate) onUpdate() + } + + fun updateOffset(offset: TextFieldValue) { + this.offset = offset.asValidatedDouble() + onUpdate() + } + + fun updateScale(scale: TextFieldValue) { + this.scale = scale.asValidatedDouble() + onUpdate() + } + } + + data class FeatureSelectionIdentifier( + val featureId: Long, + val durationPlottingMode: DurationPlottingMode + ) + + data class FeaturePathViewData( + val id: FeatureSelectionIdentifier, + val path: String, + ) + + var featurePaths: List by mutableStateOf(emptyList()) + + var lineGraphFeatures by mutableStateOf(emptyList()) + private set + + private val isTimeBasedRange = snapshotFlow { lineGraphFeatures } + .map { lgfs -> lgfs.any { it.durationPlottingMode == DurationPlottingMode.DURATION_IF_POSSIBLE } } + .stateIn(viewModelScope, SharingStarted.Lazily, false) + + private val featureTextFields = mutableListOf() + + private var lineGraph = LineGraphWithFeatures( + id = 0L, + graphStatId = 0L, + features = listOf(), + sampleSize = null, + yRangeType = YRangeType.DYNAMIC, + yFrom = 0.0, + yTo = 1.0, + endDate = GraphEndDate.Latest + ) + + override fun updateConfig() { + lineGraph = lineGraph.copy( + sampleSize = selectedDuration.temporalAmount, + endDate = sampleEndingAt.asGraphEndDate(), + yRangeType = yRangeType, + features = lineGraphFeatures.mapIndexed { index, f -> + f.copy( + name = featureTextFields[index].name.text, + offset = featureTextFields[index].offset.text.toDoubleOrNull() ?: 0.0, + scale = featureTextFields[index].scale.text.toDoubleOrNull() ?: 1.0 + ) + }, + yFrom = getYFrom(), + yTo = getYTo() + ) + } + + private fun getYFrom(): Double = + if (isTimeBasedRange.value) yRangeFromDurationViewModel.getDurationAsDouble() + else yRangeFrom.text.toDoubleOrNull() ?: 0.0 + + private fun getYTo(): Double = + if (isTimeBasedRange.value) yRangeToDurationViewModel.getDurationAsDouble() + else yRangeTo.text.toDoubleOrNull() ?: 1.0 + + + override fun getConfig(): GraphStatConfigEvent.ConfigData.LineGraphConfigData { + return GraphStatConfigEvent.ConfigData.LineGraphConfigData(lineGraph) + } + + override suspend fun validate(): ValidationException? { + if (lineGraph.features.isEmpty()) { + return ValidationException(R.string.graph_stat_validation_no_line_graph_features) + } + lineGraph.features.forEach { f -> + if (f.colorIndex !in dataVisColorList.indices) { + return ValidationException(R.string.graph_stat_validation_unrecognised_color) + } + } + if (lineGraph.features.any { it.durationPlottingMode == DurationPlottingMode.DURATION_IF_POSSIBLE } + && !lineGraph.features.all { it.durationPlottingMode == DurationPlottingMode.DURATION_IF_POSSIBLE }) { + return ValidationException(R.string.graph_stat_validation_mixed_time_value_line_graph_features) + } + if (lineGraph.yRangeType == YRangeType.FIXED && lineGraph.yFrom >= lineGraph.yTo) { + return ValidationException(R.string.graph_stat_validation_bad_fixed_range) + } + return null + } + + fun onAddLineGraphFeatureClicked() = viewModelScope.launch { + val firstFeature = featurePathProvider.features.firstOrNull() + val featureName = firstFeature?.name ?: "" + val isDuration = firstFeature?.featureId?.let { + featurePathProvider.getDataSampleProperties(it)?.isDuration + } == true + val durationPlottingMode = + if (isDuration) DurationPlottingMode.DURATION_IF_POSSIBLE + else DurationPlottingMode.NONE + featureTextFields.add(FeatureTextFields(featureName)) + lineGraphFeatures = lineGraphFeatures.toMutableList().apply { + add( + LineGraphFeature( + id = 0L, + lineGraphId = -1L, + featureId = firstFeature?.featureId ?: -1L, + name = featureName, + colorIndex = (size * dataVisColorGenerator) % dataVisColorList.size, + averagingMode = LineGraphAveraginModes.NO_AVERAGING, + plottingMode = LineGraphPlottingModes.WHEN_TRACKED, + pointStyle = LineGraphPointStyle.NONE, + offset = 0.toDouble(), + scale = 1.toDouble(), + durationPlottingMode = durationPlottingMode + ) + ) + } + onUpdate() + } + + private fun initFeaturePaths() { + featurePaths = featurePathProvider.dataSourceDataAlphabetically() + .flatMap { + val id = it.feature.featureId + val path = it.path + if (it.dataProperties?.isDuration == true) sequenceOf( + FeaturePathViewData( + id = FeatureSelectionIdentifier( + featureId = id, + durationPlottingMode = DurationPlottingMode.DURATION_IF_POSSIBLE + ), + path = path, + ), + FeaturePathViewData( + id = FeatureSelectionIdentifier( + featureId = id, + durationPlottingMode = DurationPlottingMode.HOURS + ), + path = path + ), + FeaturePathViewData( + id = FeatureSelectionIdentifier( + featureId = id, + durationPlottingMode = DurationPlottingMode.MINUTES + ), + path = path + ), + FeaturePathViewData( + id = FeatureSelectionIdentifier( + featureId = id, + durationPlottingMode = DurationPlottingMode.SECONDS + ), + path = path + ), + ) + else sequenceOf( + FeaturePathViewData( + id = FeatureSelectionIdentifier( + featureId = id, + durationPlottingMode = DurationPlottingMode.NONE + ), + path = path + ) + ) + } + } + + override fun onDataLoaded(config: Any?) { + + val lgConfig = config as? LineGraphWithFeatures + + initFeaturePaths() + + timeRangeConfigBehaviour.onConfigLoaded( + sampleSize = lgConfig?.sampleSize, + endingAt = lgConfig?.endDate + ) + + yRangeConfigBehaviour.onConfigLoaded( + yRangeType = lgConfig?.yRangeType, + yFrom = lgConfig?.yFrom, + yTo = lgConfig?.yTo, + timeBasedRange = isTimeBasedRange + ) + + lgConfig?.let { + lineGraph = it + lineGraphFeatures = it.features + it.features.forEach { lgf -> + featureTextFields.add( + FeatureTextFields( + lgf.name, + lgf.offset.toString(), + lgf.scale.toString() + ) + ) + } + } + + viewModelScope.launch { + isTimeBasedRange.drop(1).collect { + onUpdate() + } + } + } + + fun removeLineGraphFeature(index: Int) { + lineGraphFeatures = lineGraphFeatures.toMutableList().apply { removeAt(index) } + featureTextFields.removeAt(index) + onUpdate() + } + + fun updateLineGraphFeature(index: Int, newLgf: LineGraphFeature) = viewModelScope.launch { + val newFeatureName = featurePathProvider.features + .firstOrNull { it.featureId == newLgf.featureId }?.name ?: "" + val oldFeatureName = featurePathProvider.features + .firstOrNull { it.featureId == lineGraphFeatures[index].featureId }?.name ?: "" + + lineGraphFeatures = lineGraphFeatures.toMutableList().apply { + val textFields = featureTextFields[index] + if (textFields.name.text == oldFeatureName) { + textFields.updateName( + TextFieldValue( + newFeatureName, + TextRange(newFeatureName.length) + ), + triggerUpdate = false + ) + } + set( + index, newLgf.copy( + name = textFields.name.text, + offset = textFields.offset.text.toDoubleOrNull() ?: 0.toDouble(), + scale = textFields.scale.text.toDoubleOrNull() ?: 1.toDouble() + ) + ) + } + onUpdate() + } + + fun getTextFieldsFor(index: Int): FeatureTextFields { + return featureTextFields[index] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/viewmodel/PieChartConfigViewModel.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/viewmodel/PieChartConfigViewModel.kt new file mode 100644 index 000000000..c80d3dcab --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/viewmodel/PieChartConfigViewModel.kt @@ -0,0 +1,120 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.graphstatinput.configviews.viewmodel + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.GraphEndDate +import com.samco.trackandgraph.base.database.dto.PieChart +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.DefaultDispatcher +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.base.model.di.MainDispatcher +import com.samco.trackandgraph.graphstatinput.GraphStatConfigEvent +import com.samco.trackandgraph.graphstatinput.configviews.behaviour.SingleFeatureConfigBehaviour +import com.samco.trackandgraph.graphstatinput.configviews.behaviour.SingleFeatureConfigBehaviourImpl +import com.samco.trackandgraph.graphstatinput.configviews.behaviour.TimeRangeConfigBehaviour +import com.samco.trackandgraph.graphstatinput.configviews.behaviour.TimeRangeConfigBehaviourImpl +import com.samco.trackandgraph.graphstatproviders.GraphStatInteractorProvider +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +@HiltViewModel +class PieChartConfigViewModel @Inject constructor( + @IODispatcher private val io: CoroutineDispatcher, + @DefaultDispatcher private val default: CoroutineDispatcher, + @MainDispatcher private val ui: CoroutineDispatcher, + gsiProvider: GraphStatInteractorProvider, + dataInteractor: DataInteractor, + private val timeRangeConfigBehaviour: TimeRangeConfigBehaviourImpl = TimeRangeConfigBehaviourImpl(), + private val singleFeatureConfigBehaviour: SingleFeatureConfigBehaviourImpl = SingleFeatureConfigBehaviourImpl() +) : GraphStatConfigViewModelBase( + io, + default, + ui, + gsiProvider, + dataInteractor +), TimeRangeConfigBehaviour by timeRangeConfigBehaviour, + SingleFeatureConfigBehaviour by singleFeatureConfigBehaviour { + + init { + timeRangeConfigBehaviour.initTimeRangeConfigBehaviour { onUpdate() } + singleFeatureConfigBehaviour.initSingleFeatureConfigBehaviour(onUpdate = { onUpdate() }) + } + + var sumByCount: Boolean by mutableStateOf(false) + private set + + private var pieChart = PieChart( + id = 0L, + graphStatId = 0L, + featureId = -1L, + sampleSize = null, + endDate = GraphEndDate.Latest, + sumByCount = false + ) + + override fun updateConfig() { + pieChart = pieChart.copy( + featureId = this.featureId ?: -1L, + sampleSize = selectedDuration.temporalAmount, + endDate = sampleEndingAt.asGraphEndDate(), + sumByCount = sumByCount + ) + } + + override fun getConfig(): GraphStatConfigEvent.ConfigData.PieChartConfigData { + return GraphStatConfigEvent.ConfigData.PieChartConfigData(pieChart) + } + + override suspend fun validate(): GraphStatConfigEvent.ValidationException? { + val id = pieChart.featureId + if (id == -1L) { + return GraphStatConfigEvent.ValidationException(R.string.graph_stat_validation_no_line_graph_features) + } + return null + } + + override fun onDataLoaded(config: Any?) { + val pc = config as? PieChart + + val featureMap = featurePathProvider.sortedFeatureMap() + + singleFeatureConfigBehaviour.onConfigLoaded( + map = featureMap, + featureId = pc?.featureId ?: featureMap.keys.first() + ) + + timeRangeConfigBehaviour.onConfigLoaded( + sampleSize = pc?.sampleSize, + endingAt = pc?.endDate + ) + + pc?.let { + this.pieChart = it + sumByCount = it.sumByCount + } + } + + fun updateSumByCount(sumByCount: Boolean) { + this.sumByCount = sumByCount + onUpdate() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/viewmodel/TimeHistogramConfigViewModel.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/viewmodel/TimeHistogramConfigViewModel.kt new file mode 100644 index 000000000..dbb8135e2 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/configviews/viewmodel/TimeHistogramConfigViewModel.kt @@ -0,0 +1,131 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.graphstatinput.configviews.viewmodel + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.GraphEndDate +import com.samco.trackandgraph.base.database.dto.TimeHistogram +import com.samco.trackandgraph.base.database.dto.TimeHistogramWindow +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.DefaultDispatcher +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.base.model.di.MainDispatcher +import com.samco.trackandgraph.graphstatinput.GraphStatConfigEvent +import com.samco.trackandgraph.graphstatinput.configviews.behaviour.SingleFeatureConfigBehaviour +import com.samco.trackandgraph.graphstatinput.configviews.behaviour.SingleFeatureConfigBehaviourImpl +import com.samco.trackandgraph.graphstatinput.configviews.behaviour.TimeRangeConfigBehaviour +import com.samco.trackandgraph.graphstatinput.configviews.behaviour.TimeRangeConfigBehaviourImpl +import com.samco.trackandgraph.graphstatproviders.GraphStatInteractorProvider +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +@HiltViewModel +class TimeHistogramConfigViewModel @Inject constructor( + @IODispatcher private val io: CoroutineDispatcher, + @DefaultDispatcher private val default: CoroutineDispatcher, + @MainDispatcher private val ui: CoroutineDispatcher, + gsiProvider: GraphStatInteractorProvider, + dataInteractor: DataInteractor, + private val timeRangeConfigBehaviour: TimeRangeConfigBehaviourImpl = TimeRangeConfigBehaviourImpl(), + private val singleFeatureConfigBehaviour: SingleFeatureConfigBehaviourImpl = SingleFeatureConfigBehaviourImpl(), +) : GraphStatConfigViewModelBase( + io, + default, + ui, + gsiProvider, + dataInteractor +), TimeRangeConfigBehaviour by timeRangeConfigBehaviour, + SingleFeatureConfigBehaviour by singleFeatureConfigBehaviour { + + init { + timeRangeConfigBehaviour.initTimeRangeConfigBehaviour { onUpdate() } + singleFeatureConfigBehaviour.initSingleFeatureConfigBehaviour(onUpdate = { onUpdate() }) + } + + var selectedWindow: TimeHistogramWindow by mutableStateOf(TimeHistogramWindow.DAY) + private set + + var sumByCount: Boolean by mutableStateOf(false) + private set + + private var timeHistogram = TimeHistogram( + id = 0L, + graphStatId = 0L, + featureId = -1L, + sampleSize = null, + window = TimeHistogramWindow.DAY, + sumByCount = false, + endDate = GraphEndDate.Latest + ) + + override fun updateConfig() { + timeHistogram = timeHistogram.copy( + featureId = singleFeatureConfigBehaviour.featureId ?: -1L, + sampleSize = timeRangeConfigBehaviour.selectedDuration.temporalAmount, + window = selectedWindow, + sumByCount = sumByCount, + endDate = timeRangeConfigBehaviour.sampleEndingAt.asGraphEndDate() + ) + } + + override fun getConfig(): GraphStatConfigEvent.ConfigData.TimeHistogramConfigData { + return GraphStatConfigEvent.ConfigData.TimeHistogramConfigData(timeHistogram) + } + + override suspend fun validate(): GraphStatConfigEvent.ValidationException? { + return if (singleFeatureConfigBehaviour.featureId == -1L) { + GraphStatConfigEvent.ValidationException(R.string.graph_stat_validation_no_line_graph_features) + } else null + } + + override fun onDataLoaded(config: Any?) { + + val timeHist = config as? TimeHistogram + + val featureMap = featurePathProvider.sortedFeatureMap() + + singleFeatureConfigBehaviour.onConfigLoaded( + map = featureMap, + featureId = timeHist?.featureId ?: featureMap.keys.first() + ) + + timeRangeConfigBehaviour.onConfigLoaded( + sampleSize = timeHist?.sampleSize, + endingAt = timeHist?.endDate + ) + + timeHist?.let { + this.timeHistogram = config + selectedWindow = config.window + sumByCount = config.sumByCount + } + } + + fun updateWindow(window: TimeHistogramWindow) { + this.selectedWindow = window + onUpdate() + } + + fun updateSumByCount(sumByCount: Boolean) { + this.sumByCount = sumByCount + onUpdate() + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/customviews/FilterByLabelSection.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/customviews/FilterByLabelSection.kt new file mode 100644 index 000000000..94c37c3ae --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/customviews/FilterByLabelSection.kt @@ -0,0 +1,89 @@ +package com.samco.trackandgraph.graphstatinput.customviews + +import androidx.compose.foundation.layout.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import com.samco.trackandgraph.R +import com.samco.trackandgraph.graphstatinput.configviews.behaviour.FilterableFeatureConfigBehaviour +import com.samco.trackandgraph.ui.compose.ui.* + +@Composable +fun FilterByLabelSection(viewModel: FilterableFeatureConfigBehaviour) { + val focusRequester = remember { FocusRequester() } + + CheckboxLabeledExpandingSection( + checked = viewModel.filterByLabel, + onCheckedChanged = { viewModel.updateFilterByLabel(it) }, + label = stringResource(id = R.string.filter_by_label), + focusRequester = focusRequester + ) { + DialogInputSpacing() + when { + viewModel.loadingLabels -> LoadingIndicator(it) + viewModel.availableLabels.isEmpty() -> NoLabelsIndicator(it) + else -> LabelsFadingLazyRow(it, viewModel) + } + CardPadding() + } +} + +@Composable +fun NoLabelsIndicator(modifier: Modifier) = Text( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight(), + text = stringResource(id = R.string.no_labels), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center +) + +@Composable +private fun LoadingIndicator( + modifier: Modifier +) = Box( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight(), + contentAlignment = Alignment.Center +) { + CircularProgressIndicator() +} + +@Composable +private fun LabelsFadingLazyRow( + modifier: Modifier, + viewModel: FilterableFeatureConfigBehaviour +) { + FadingLazyRow( + modifier = modifier, + fadeColor = MaterialTheme.colors.background, + horizontalArrangement = Arrangement.spacedBy( + dimensionResource(id = R.dimen.dialog_input_spacing), + Alignment.CenterHorizontally + ) + ) { + items(viewModel.availableLabels.size) { index -> + val label = viewModel.availableLabels[index] + val selected = viewModel.selectedLabels.contains(label) + TextChip( + text = label, + isSelected = selected, + onClick = { + viewModel.updateSelectedLabels( + if (selected) viewModel.selectedLabels - label + else viewModel.selectedLabels + label + ) + } + ) + } + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/customviews/FilterByValueSection.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/customviews/FilterByValueSection.kt new file mode 100644 index 000000000..e74830bef --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/customviews/FilterByValueSection.kt @@ -0,0 +1,65 @@ +package com.samco.trackandgraph.graphstatinput.customviews + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import com.samco.trackandgraph.R +import com.samco.trackandgraph.ui.compose.ui.CheckboxLabeledExpandingSection +import com.samco.trackandgraph.graphstatinput.configviews.behaviour.FilterableFeatureConfigBehaviour +import com.samco.trackandgraph.ui.compose.ui.MiniNumericTextField +import com.samco.trackandgraph.ui.compose.ui.DialogInputSpacing + +@Composable +fun FilterByValueSection(viewModel: FilterableFeatureConfigBehaviour) { + val focusRequester = remember { FocusRequester() } + + CheckboxLabeledExpandingSection( + checked = viewModel.filterByRange, + onCheckedChanged = { viewModel.updateFilterByRange(it) }, + label = stringResource(id = R.string.filter_by_value), + focusRequester = focusRequester + ) { + DialogInputSpacing() + Row(Modifier.padding(dimensionResource(id = R.dimen.card_padding))) { + Text( + modifier = Modifier.alignByBaseline(), + text = stringResource(id = R.string.from), + style = MaterialTheme.typography.body1 + ) + + MiniNumericTextField( + modifier = it + .weight(1f) + .alignByBaseline(), + textAlign = TextAlign.Center, + textFieldValue = viewModel.fromValue, + onValueChange = { viewModel.updateFromValue(it) } + ) + + DialogInputSpacing() + + Text( + modifier = Modifier.alignByBaseline(), + text = stringResource(id = R.string.to), + style = MaterialTheme.typography.body1 + ) + + MiniNumericTextField( + modifier = Modifier + .weight(1f) + .alignByBaseline(), + textAlign = TextAlign.Center, + textFieldValue = viewModel.toValue, + onValueChange = { viewModel.updateToValue(it) } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/customviews/GraphStatDurationSpinner.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/customviews/GraphStatDurationSpinner.kt new file mode 100644 index 000000000..c52002963 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/customviews/GraphStatDurationSpinner.kt @@ -0,0 +1,377 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.graphstatinput.customviews + +import android.content.Context +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import com.samco.trackandgraph.R +import com.samco.trackandgraph.ui.compose.ui.CustomConfirmCancelDialog +import com.samco.trackandgraph.ui.compose.ui.LabeledRow +import com.samco.trackandgraph.ui.compose.ui.DialogInputSpacing +import com.samco.trackandgraph.ui.compose.ui.Spinner +import com.samco.trackandgraph.ui.compose.ui.TextMapSpinner +import org.threeten.bp.Duration +import org.threeten.bp.Period +import org.threeten.bp.temporal.ChronoUnit +import org.threeten.bp.temporal.TemporalAmount + +enum class GraphStatSampleSizeOption { + ALL_DATA, + A_DAY, + A_WEEK, + A_MONTH, + THREE_MONTHS, + SIX_MONTHS, + A_YEAR, + CUSTOM +} + +sealed class GraphStatSampleSize private constructor( + val temporalAmount: TemporalAmount?, + val option: GraphStatSampleSizeOption +) { + + object AllData : GraphStatSampleSize( + null, + GraphStatSampleSizeOption.ALL_DATA + ) + + object ADay : GraphStatSampleSize( + Period.ofDays(1), + GraphStatSampleSizeOption.A_DAY + ) + + object AWeek : GraphStatSampleSize( + Period.ofWeeks(1), + GraphStatSampleSizeOption.A_WEEK + ) + + object AMonth : GraphStatSampleSize( + Period.ofMonths(1), + GraphStatSampleSizeOption.A_MONTH + ) + + object ThreeMonths : GraphStatSampleSize( + Period.ofMonths(3), + GraphStatSampleSizeOption.THREE_MONTHS + ) + + object SixMonths : GraphStatSampleSize( + Period.ofMonths(6), + GraphStatSampleSizeOption.SIX_MONTHS + ) + + object AYear : GraphStatSampleSize( + Period.ofYears(1), + GraphStatSampleSizeOption.A_YEAR + ) + + data class Custom(val customTemporalAmount: TemporalAmount) : GraphStatSampleSize( + customTemporalAmount, + GraphStatSampleSizeOption.CUSTOM + ) { + constructor(amount: Long, unit: ChronoUnit) : this( + getTemporalAmount(QuantityAndUnit(amount, unit)) + ) + } + + companion object { + + private val valueMap = mapOf( + null to AllData, + + //These are here for legacy reasons. Some people might have graphs from the old + // version which did not use periods and only used durations. As such their selected + // duration will not be recognised unless we map the old versions too. + Duration.ofDays(1) to ADay, + Duration.ofDays(7) to AWeek, + Duration.ofDays(31) to AMonth, + Duration.ofDays(93) to ThreeMonths, + Duration.ofDays(183) to SixMonths, + Duration.ofDays(365) to AYear, + + Period.ofDays(1) to ADay, + Period.ofWeeks(1) to AWeek, + Period.ofMonths(1) to AMonth, + Period.ofMonths(3) to ThreeMonths, + Period.ofMonths(6) to SixMonths, + Period.ofYears(1) to AYear + ) + + fun fromTemporalAmount(temporalAmount: TemporalAmount?): GraphStatSampleSize { + return temporalAmount?.let { + valueMap[temporalAmount] ?: Custom(temporalAmount) + } ?: AllData + } + + fun fromOption(option: GraphStatSampleSizeOption): GraphStatSampleSize { + return mapOf( + GraphStatSampleSizeOption.ALL_DATA to AllData, + GraphStatSampleSizeOption.A_DAY to ADay, + GraphStatSampleSizeOption.A_WEEK to AWeek, + GraphStatSampleSizeOption.A_MONTH to AMonth, + GraphStatSampleSizeOption.THREE_MONTHS to ThreeMonths, + GraphStatSampleSizeOption.SIX_MONTHS to SixMonths, + GraphStatSampleSizeOption.A_YEAR to AYear + )[option] ?: throw IllegalArgumentException("Unknown option: $option") + } + } +} + + +@Composable +fun GraphStatDurationSpinner( + modifier: Modifier = Modifier, + label: String = stringResource(id = R.string.sample_size), + selectedDuration: GraphStatSampleSize, + onDurationSelected: (GraphStatSampleSize) -> Unit +) { + + val context = LocalContext.current + + var showCustomDialog by remember { mutableStateOf(false) } + + if (showCustomDialog) { + CustomDurationDialog( + selectedDuration, + onDismissRequest = { showCustomDialog = false }, + onDurationSelected = { + showCustomDialog = false + onDurationSelected(it) + } + ) + } + + LabeledRow( + modifier = modifier, + label = label, + paddingValues = PaddingValues( + start = dimensionResource(id = R.dimen.card_padding) + ) + ) { + val spinnerItems = mapOf( + GraphStatSampleSizeOption.ALL_DATA to stringResource(id = R.string.graph_time_durations_all_data), + GraphStatSampleSizeOption.A_DAY to stringResource(id = R.string.graph_time_durations_a_day), + GraphStatSampleSizeOption.A_WEEK to stringResource(id = R.string.graph_time_durations_a_week), + GraphStatSampleSizeOption.A_MONTH to stringResource(id = R.string.graph_time_durations_a_month), + GraphStatSampleSizeOption.THREE_MONTHS to stringResource(id = R.string.graph_time_durations_three_months), + GraphStatSampleSizeOption.SIX_MONTHS to stringResource(id = R.string.graph_time_durations_six_months), + GraphStatSampleSizeOption.A_YEAR to stringResource(id = R.string.graph_time_durations_a_year), + GraphStatSampleSizeOption.CUSTOM to stringResource(id = R.string.custom) + ) + + Spinner( + items = spinnerItems.keys.toList(), + selectedItem = selectedDuration.option, + onItemSelected = { + if (it == GraphStatSampleSizeOption.CUSTOM) { + showCustomDialog = true + } else { + onDurationSelected(GraphStatSampleSize.fromOption(it)) + } + }, + selectedItemFactory = { modifier, item, _ -> + val text = when (item) { + GraphStatSampleSizeOption.CUSTOM -> { + selectedDuration.temporalAmount?.let { + formatTemporalAmountAsSampleSize(context, it) + } ?: spinnerItems[item] ?: ""//should never be empty + } + + else -> spinnerItems[item] ?: ""//should never be empty + } + + Text( + modifier = modifier.weight(1f), + text = text, + fontSize = MaterialTheme.typography.body1.fontSize, + fontWeight = MaterialTheme.typography.body1.fontWeight, + ) + }, + dropdownItemFactory = { item, _ -> + Text( + text = spinnerItems[item] ?: "", + fontSize = MaterialTheme.typography.body1.fontSize, + fontWeight = MaterialTheme.typography.body1.fontWeight + ) + } + ) + } +} + +private data class QuantityAndUnit( + val quantity: Long, + val unit: ChronoUnit +) + +private fun getQuantityAndUnit(temporalAmount: TemporalAmount): QuantityAndUnit { + for (unit in temporalAmount.units) { + val quantity = temporalAmount.get(unit) + if (quantity > 0) { + return when { + unit == ChronoUnit.DAYS && quantity % 7 == 0L -> QuantityAndUnit(quantity / 7, ChronoUnit.WEEKS) + unit == ChronoUnit.SECONDS && quantity % (60*60) == 0L -> QuantityAndUnit(quantity / (60*60), ChronoUnit.HOURS) + unit == ChronoUnit.SECONDS && quantity % 60 == 0L -> QuantityAndUnit(quantity / 60, ChronoUnit.MINUTES) + unit == ChronoUnit.NANOS -> QuantityAndUnit(quantity / 1_000_000_000L, ChronoUnit.SECONDS) + else -> QuantityAndUnit(quantity, unit as ChronoUnit) + } + } + } + return QuantityAndUnit(0, ChronoUnit.DAYS) +} + +private fun getTemporalAmount(quantityAndUnit: QuantityAndUnit): TemporalAmount { + val quantity = quantityAndUnit.quantity + return when (quantityAndUnit.unit) { + ChronoUnit.YEARS -> Period.ofYears(quantity.toInt()) + ChronoUnit.MONTHS -> Period.ofMonths(quantity.toInt()) + ChronoUnit.WEEKS -> Period.ofWeeks(quantity.toInt()) + ChronoUnit.DAYS -> Period.ofDays(quantity.toInt()) + ChronoUnit.HOURS -> Duration.ofHours(quantity) + ChronoUnit.MINUTES -> Duration.ofMinutes(quantity) + ChronoUnit.SECONDS -> Duration.ofSeconds(quantity) + else -> throw IllegalArgumentException("Unsupported unit: ${quantityAndUnit.unit}") + } +} + +private fun formatTemporalAmountAsSampleSize( + context: Context, + temporalAmount: TemporalAmount +): String { + val quantityAndUnit = getQuantityAndUnit(temporalAmount) + val quantity = quantityAndUnit.quantity.toInt() + val res = context.resources + + return when (quantityAndUnit.unit) { + ChronoUnit.YEARS -> res.getQuantityString(R.plurals.years, quantity, quantity) + ChronoUnit.MONTHS -> res.getQuantityString(R.plurals.months, quantity, quantity) + ChronoUnit.WEEKS -> res.getQuantityString(R.plurals.weeks, quantity, quantity) + ChronoUnit.DAYS -> res.getQuantityString(R.plurals.days, quantity, quantity) + ChronoUnit.HOURS -> res.getQuantityString(R.plurals.hours, quantity, quantity) + ChronoUnit.MINUTES -> res.getQuantityString(R.plurals.minutes, quantity, quantity) + ChronoUnit.SECONDS -> res.getQuantityString(R.plurals.seconds, quantity, quantity) + else -> temporalAmount.toString() + } +} + +@Composable +private fun CustomDurationDialog( + selectedDuration: GraphStatSampleSize, + onDismissRequest: () -> Unit, + onDurationSelected: (GraphStatSampleSize) -> Unit +) { + val quantityAndUnit = remember(selectedDuration) { + selectedDuration.temporalAmount?.let { + getQuantityAndUnit(it) + } ?: QuantityAndUnit(1, ChronoUnit.WEEKS) + } + + var selectedNumber by remember(quantityAndUnit) { + mutableStateOf( + TextFieldValue( + quantityAndUnit.quantity.toString(), + selection = TextRange(quantityAndUnit.quantity.toString().length) + ) + ) + } + + var selectedUnit by remember(quantityAndUnit) { + mutableStateOf(quantityAndUnit.unit) + } + + val strings = mapOf( + ChronoUnit.SECONDS to stringResource(id = R.string.seconds_generic), + ChronoUnit.MINUTES to stringResource(id = R.string.minutes_generic), + ChronoUnit.HOURS to stringResource(id = R.string.hours_generic), + ChronoUnit.DAYS to stringResource(id = R.string.days_generic), + ChronoUnit.WEEKS to stringResource(id = R.string.weeks_generic), + ChronoUnit.MONTHS to stringResource(id = R.string.months_generic), + ChronoUnit.YEARS to stringResource(id = R.string.years_generic), + ) + + CustomConfirmCancelDialog( + onDismissRequest = onDismissRequest, + continueText = R.string.ok, + onConfirm = { + onDurationSelected( + GraphStatSampleSize.Custom( + selectedNumber.text.toLongOrNull() ?: 0, + selectedUnit + ) + ) + } + ) { + val focusRequester = remember { FocusRequester() } + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + modifier = Modifier + .focusRequester(focusRequester) + .weight(0.6f), + value = selectedNumber, + textStyle = MaterialTheme.typography.h5.copy( + textAlign = TextAlign.Center, + ), + maxLines = 1, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + onValueChange = { tfv -> + val filtered = tfv.copy(text = tfv.text.filter { it.isDigit() }) + if (filtered != selectedNumber) selectedNumber = filtered + } + ) + + DialogInputSpacing() + + TextMapSpinner( + modifier = Modifier.weight(1f), + strings = strings, + selectedItem = selectedUnit, + onItemSelected = { + if (it != selectedUnit) selectedUnit = it + } + ) + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/customviews/GraphStatEndingAtSpinner.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/customviews/GraphStatEndingAtSpinner.kt new file mode 100644 index 000000000..0c6df549e --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/customviews/GraphStatEndingAtSpinner.kt @@ -0,0 +1,139 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.graphstatinput.customviews + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.GraphEndDate +import com.samco.trackandgraph.base.helpers.formatDayMonthYear +import com.samco.trackandgraph.ui.compose.compositionlocals.LocalSettings +import com.samco.trackandgraph.ui.compose.ui.LabeledRow +import com.samco.trackandgraph.ui.compose.ui.Spinner +import com.samco.trackandgraph.ui.compose.ui.showDateDialog +import org.threeten.bp.OffsetDateTime + +enum class SampleEndingAtOption { + NOW, LATEST, CUSTOM +} + +sealed interface SampleEndingAt { + val option: SampleEndingAtOption + + fun asGraphEndDate(): GraphEndDate + + companion object { + fun fromGraphEndDate(endDate: GraphEndDate): SampleEndingAt { + return when (endDate) { + is GraphEndDate.Latest -> Latest + is GraphEndDate.Now -> Now + is GraphEndDate.Date -> Custom(endDate.date) + } + } + } + + object Latest : SampleEndingAt { + override val option = SampleEndingAtOption.LATEST + override fun asGraphEndDate() = GraphEndDate.Latest + } + + object Now : SampleEndingAt { + override val option = SampleEndingAtOption.NOW + override fun asGraphEndDate() = GraphEndDate.Now + } + + data class Custom(val dateTime: OffsetDateTime) : SampleEndingAt { + override val option = SampleEndingAtOption.CUSTOM + override fun asGraphEndDate() = GraphEndDate.Date(dateTime) + } +} + +@Composable +fun GraphStatEndingAtSpinner( + modifier: Modifier, + sampleEndingAt: SampleEndingAt, + onSampleEndingAtChanged: (SampleEndingAt) -> Unit +) { + + val strLatest = stringResource(id = R.string.ending_at_latest) + val strCustom = stringResource(id = R.string.ending_at_custom_date) + val strNow = stringResource(id = R.string.ending_at_now) + + LabeledRow( + label = stringResource(id = R.string.ending_at_colon), + paddingValues = PaddingValues(start = dimensionResource(id = R.dimen.card_padding)) + ) { + val spinnerItems = mapOf( + SampleEndingAtOption.LATEST to strLatest, + SampleEndingAtOption.NOW to strNow, + SampleEndingAtOption.CUSTOM to strCustom + ) + + val context = LocalContext.current + val firstDayOfWeek = LocalSettings.current.firstDayOfWeek + + Spinner( + modifier = modifier, + items = spinnerItems.keys.toList(), + selectedItem = sampleEndingAt.option, + onItemSelected = { option -> + when (option) { + SampleEndingAtOption.LATEST -> onSampleEndingAtChanged(SampleEndingAt.Latest) + SampleEndingAtOption.NOW -> onSampleEndingAtChanged(SampleEndingAt.Now) + SampleEndingAtOption.CUSTOM -> showDateDialog( + context = context, + firstDayOfWeek = firstDayOfWeek, + onDateSelected = { + onSampleEndingAtChanged(SampleEndingAt.Custom(it)) + } + ) + } + }, + selectedItemFactory = { modifier, item, expanded -> + val text = when (item) { + SampleEndingAtOption.LATEST -> strLatest + SampleEndingAtOption.NOW -> strNow + SampleEndingAtOption.CUSTOM -> { + if (sampleEndingAt is SampleEndingAt.Custom) + formatDayMonthYear(context, sampleEndingAt.dateTime) + else strCustom + } + } + + Text( + modifier = modifier.weight(1f), + text = text, + fontSize = MaterialTheme.typography.body1.fontSize, + fontWeight = MaterialTheme.typography.body1.fontWeight, + ) + }, + dropdownItemFactory = { item, _ -> + Text( + text = spinnerItems[item] ?: "", + fontSize = MaterialTheme.typography.body1.fontSize, + fontWeight = MaterialTheme.typography.body1.fontWeight + ) + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/customviews/GraphStatYRangeTypeSpinner.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/customviews/GraphStatYRangeTypeSpinner.kt new file mode 100644 index 000000000..dc934b4f8 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/customviews/GraphStatYRangeTypeSpinner.kt @@ -0,0 +1,53 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.graphstatinput.customviews + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.YRangeType +import com.samco.trackandgraph.ui.compose.ui.LabeledRow +import com.samco.trackandgraph.ui.compose.ui.TextMapSpinner + +@Composable +fun GraphStatYRangeTypeSpinner( + yRangeType: YRangeType, + onYRangeTypeSelected: (YRangeType) -> Unit +) { + LabeledRow( + label = stringResource(id = R.string.range_style), + paddingValues = PaddingValues( + start = dimensionResource(id = R.dimen.card_padding) + ) + ) { + val strings = stringArrayResource(id = R.array.y_range_styles) + + val spinnerItems = mapOf( + YRangeType.DYNAMIC to strings[0], + YRangeType.FIXED to strings[1] + ) + + TextMapSpinner( + strings = spinnerItems, + selectedItem = yRangeType, + onItemSelected = onYRangeTypeSelected + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatinput/customviews/YRangeFromToInputs.kt b/app/src/main/java/com/samco/trackandgraph/graphstatinput/customviews/YRangeFromToInputs.kt new file mode 100644 index 000000000..ae2137aeb --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatinput/customviews/YRangeFromToInputs.kt @@ -0,0 +1,137 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatinput.customviews + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import com.samco.trackandgraph.R +import com.samco.trackandgraph.graphstatinput.configviews.behaviour.YRangeConfigBehaviour +import com.samco.trackandgraph.ui.compose.ui.DurationInput +import com.samco.trackandgraph.ui.compose.ui.MiniNumericTextField + +@Composable +fun YRangeFromToInputs( + viewModel: YRangeConfigBehaviour, + fromEnabled: Boolean = true +) { + val toText = + if (fromEnabled) stringResource(id = R.string.to) + else stringResource(id = R.string.y_range_max) + val timeBased = viewModel.timeBasedRange.collectAsState(initial = false).value + if (timeBased) TimeBasedRangeInputs(viewModel, fromEnabled, toText) + else NumericRangeInputs(viewModel, fromEnabled, toText) +} + +@Composable +private fun TimeBasedRangeInputs( + viewModel: YRangeConfigBehaviour, + fromEnabled: Boolean, + toText: String +) = Column( + modifier = Modifier + .padding(horizontal = dimensionResource(id = R.dimen.card_padding)) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally +) { + + if (fromEnabled) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(id = R.string.from), + style = MaterialTheme.typography.subtitle2 + ) + + Spacer(modifier = Modifier.width(dimensionResource(id = R.dimen.dialog_input_spacing))) + + DurationInput( + viewModel = viewModel.yRangeFromDurationViewModel, + nextFocusDirection = FocusDirection.Down, + ) + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = toText, + style = MaterialTheme.typography.subtitle2 + ) + + Spacer(modifier = Modifier.width(dimensionResource(id = R.dimen.dialog_input_spacing))) + + DurationInput(viewModel = viewModel.yRangeToDurationViewModel) + } +} + +@Composable +private fun NumericRangeInputs( + viewModel: YRangeConfigBehaviour, + fromEnabled: Boolean, + toText: String +) = Row( + modifier = Modifier + .padding(horizontal = dimensionResource(id = R.dimen.card_padding)) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly +) { + if (fromEnabled) { + Text( + modifier = Modifier.alignByBaseline(), + text = stringResource(id = R.string.from), + style = MaterialTheme.typography.subtitle2 + ) + + MiniNumericTextField( + modifier = Modifier + .weight(1f) + .alignByBaseline(), + textAlign = TextAlign.Center, + textFieldValue = viewModel.yRangeFrom, + onValueChange = { viewModel.updateYRangeFrom(it) } + ) + } + + Text( + modifier = Modifier.alignByBaseline(), + text = toText, + style = MaterialTheme.typography.subtitle2 + ) + + MiniNumericTextField( + modifier = Modifier + .weight(1f) + .alignByBaseline(), + textAlign = TextAlign.Center, + textFieldValue = viewModel.yRangeTo, + onValueChange = { viewModel.updateYRangeTo(it) } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatproviders/GraphStatInteractorProvider.kt b/app/src/main/java/com/samco/trackandgraph/graphstatproviders/GraphStatInteractorProvider.kt new file mode 100644 index 000000000..86e565d5e --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatproviders/GraphStatInteractorProvider.kt @@ -0,0 +1,66 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatproviders + +import com.samco.trackandgraph.base.database.dto.GraphStatType +import com.samco.trackandgraph.graphstatproviders.datasourceadapters.* +import com.samco.trackandgraph.graphstatview.factories.* +import javax.inject.Inject + +interface GraphStatInteractorProvider { + fun getDataFactory(type: GraphStatType): ViewDataFactory<*, *> + fun getDataSourceAdapter(type: GraphStatType): GraphStatDataSourceAdapter<*> +} + +class GraphStatInteractorProviderImpl @Inject constructor( + private val lineGraphDataFactory: LineGraphDataFactory, + private val lineGraphDataSourceAdapter: LineGraphDataSourceAdapter, + private val pieChartDataFactory: PieChartDataFactory, + private val pieChartDataSourceAdapter: PieChartDataSourceAdapter, + private val averageTimeBetweenDataFactory: AverageTimeBetweenDataFactory, + private val averageTimeBetweenDataSourceAdapter: AverageTimeBetweenDataSourceAdapter, + private val timeHistogramDataFactory: TimeHistogramDataFactory, + private val timeHistogramDataSourceAdapter: TimeHistogramDataSourceAdapter, + private val lastValueDataFactory: LastValueDataFactory, + private val lastValueDataSourceAdapter: LastValueDataSourceAdapter, + private val barChartDataFactory: BarChartDataFactory, + private val barChartDataSourceAdapter: BarChartDataSourceAdapter +) : GraphStatInteractorProvider { + + override fun getDataFactory(type: GraphStatType): ViewDataFactory<*, *> { + return when (type) { + GraphStatType.LINE_GRAPH -> lineGraphDataFactory + GraphStatType.PIE_CHART -> pieChartDataFactory + GraphStatType.TIME_HISTOGRAM -> timeHistogramDataFactory + GraphStatType.AVERAGE_TIME_BETWEEN -> averageTimeBetweenDataFactory + GraphStatType.LAST_VALUE -> lastValueDataFactory + GraphStatType.BAR_CHART -> barChartDataFactory + } + } + + override fun getDataSourceAdapter(type: GraphStatType): GraphStatDataSourceAdapter<*> { + return when (type) { + GraphStatType.LINE_GRAPH -> lineGraphDataSourceAdapter + GraphStatType.PIE_CHART -> pieChartDataSourceAdapter + GraphStatType.AVERAGE_TIME_BETWEEN -> averageTimeBetweenDataSourceAdapter + GraphStatType.TIME_HISTOGRAM -> timeHistogramDataSourceAdapter + GraphStatType.LAST_VALUE -> lastValueDataSourceAdapter + GraphStatType.BAR_CHART -> barChartDataSourceAdapter + } + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatproviders/datasourceadapters/AverageTimeBetweenDataSourceAdapter.kt b/app/src/main/java/com/samco/trackandgraph/graphstatproviders/datasourceadapters/AverageTimeBetweenDataSourceAdapter.kt new file mode 100644 index 000000000..d725746d2 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatproviders/datasourceadapters/AverageTimeBetweenDataSourceAdapter.kt @@ -0,0 +1,50 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatproviders.datasourceadapters + +import com.samco.trackandgraph.base.database.dto.AverageTimeBetweenStat +import com.samco.trackandgraph.base.database.dto.GraphOrStat +import com.samco.trackandgraph.base.model.DataInteractor +import javax.inject.Inject + +class AverageTimeBetweenDataSourceAdapter @Inject constructor( + dataInteractor: DataInteractor +) : GraphStatDataSourceAdapter(dataInteractor) { + override suspend fun writeConfigToDatabase( + graphOrStat: GraphOrStat, + config: AverageTimeBetweenStat, + updateMode: Boolean + ) { + if (updateMode) dataInteractor.updateAverageTimeBetweenStat(graphOrStat, config) + else dataInteractor.insertAverageTimeBetweenStat(graphOrStat, config) + } + + override suspend fun getConfigDataFromDatabase(graphOrStatId: Long): Pair? { + val ats = + dataInteractor.getAverageTimeBetweenStatByGraphStatId(graphOrStatId) ?: return null + return Pair(ats.id, ats) + } + + override suspend fun shouldPreen(graphOrStat: GraphOrStat): Boolean { + return dataInteractor.getAverageTimeBetweenStatByGraphStatId(graphOrStat.id) == null + } + + override suspend fun duplicateGraphOrStat(graphOrStat: GraphOrStat) { + dataInteractor.duplicateAverageTimeBetweenStat(graphOrStat) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatproviders/datasourceadapters/BarChartDataSourceAdapter.kt b/app/src/main/java/com/samco/trackandgraph/graphstatproviders/datasourceadapters/BarChartDataSourceAdapter.kt new file mode 100644 index 000000000..eb4668fbd --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatproviders/datasourceadapters/BarChartDataSourceAdapter.kt @@ -0,0 +1,32 @@ +package com.samco.trackandgraph.graphstatproviders.datasourceadapters + +import com.samco.trackandgraph.base.database.dto.BarChart +import com.samco.trackandgraph.base.database.dto.GraphOrStat +import com.samco.trackandgraph.base.model.DataInteractor +import javax.inject.Inject + +class BarChartDataSourceAdapter @Inject constructor( + dataInteractor: DataInteractor +) : GraphStatDataSourceAdapter(dataInteractor) { + override suspend fun writeConfigToDatabase( + graphOrStat: GraphOrStat, + config: BarChart, + updateMode: Boolean + ) { + if (updateMode) dataInteractor.updateBarChart(graphOrStat, config) + else dataInteractor.insertBarChart(graphOrStat, config) + } + + override suspend fun getConfigDataFromDatabase(graphOrStatId: Long): Pair? { + val lvs = dataInteractor.getBarChartByGraphStatId(graphOrStatId) ?: return null + return Pair(lvs.id, lvs) + } + + override suspend fun shouldPreen(graphOrStat: GraphOrStat): Boolean { + return dataInteractor.getBarChartByGraphStatId(graphOrStat.id) == null + } + + override suspend fun duplicateGraphOrStat(graphOrStat: GraphOrStat) { + dataInteractor.duplicateBarChart(graphOrStat) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatproviders/datasourceadapters/GraphStatDataSourceAdapter.kt b/app/src/main/java/com/samco/trackandgraph/graphstatproviders/datasourceadapters/GraphStatDataSourceAdapter.kt new file mode 100644 index 000000000..a9f87a8e6 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatproviders/datasourceadapters/GraphStatDataSourceAdapter.kt @@ -0,0 +1,57 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatproviders.datasourceadapters + +import com.samco.trackandgraph.base.database.dto.GraphOrStat +import com.samco.trackandgraph.base.model.DataInteractor + +/** + * An abstract adapter for retrieving and writing graph or stat configs to a database + * + * I is the type of object to be stored and retrieved by this adapter + */ +abstract class GraphStatDataSourceAdapter( + protected val dataInteractor: DataInteractor +) { + protected abstract suspend fun writeConfigToDatabase( + graphOrStat: GraphOrStat, + config: I, + updateMode: Boolean + ) + + protected abstract suspend fun getConfigDataFromDatabase(graphOrStatId: Long): Pair? + + suspend fun getConfigData(graphOrStatId: Long): Pair? { + return (getConfigDataFromDatabase(graphOrStatId) ?: return null).let { + Pair(it.first, it.second as Any) + } + } + + @Suppress("UNCHECKED_CAST") + suspend fun writeConfig(graphOrStat: GraphOrStat, config: Any, updateMode: Boolean) { + writeConfigToDatabase(graphOrStat, config as I, updateMode) + } + + protected abstract suspend fun shouldPreen(graphOrStat: GraphOrStat): Boolean + + suspend fun preen(graphOrStat: GraphOrStat) { + if (shouldPreen(graphOrStat)) dataInteractor.deleteGraphOrStat(graphOrStat) + } + + abstract suspend fun duplicateGraphOrStat(graphOrStat: GraphOrStat) +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatproviders/datasourceadapters/LastValueDataSourceAdapter.kt b/app/src/main/java/com/samco/trackandgraph/graphstatproviders/datasourceadapters/LastValueDataSourceAdapter.kt new file mode 100644 index 000000000..cf5f00f8b --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatproviders/datasourceadapters/LastValueDataSourceAdapter.kt @@ -0,0 +1,48 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.graphstatproviders.datasourceadapters + +import com.samco.trackandgraph.base.database.dto.GraphOrStat +import com.samco.trackandgraph.base.database.dto.LastValueStat +import com.samco.trackandgraph.base.model.DataInteractor +import javax.inject.Inject + +class LastValueDataSourceAdapter @Inject constructor( + dataInteractor: DataInteractor +) : GraphStatDataSourceAdapter(dataInteractor) { + override suspend fun writeConfigToDatabase( + graphOrStat: GraphOrStat, + config: LastValueStat, + updateMode: Boolean + ) { + if (updateMode) dataInteractor.updateLastValueStat(graphOrStat, config) + else dataInteractor.insertLastValueStat(graphOrStat, config) + } + + override suspend fun getConfigDataFromDatabase(graphOrStatId: Long): Pair? { + val lvs = dataInteractor.getLastValueStatByGraphStatId(graphOrStatId) ?: return null + return Pair(lvs.id, lvs) + } + + override suspend fun shouldPreen(graphOrStat: GraphOrStat): Boolean { + return dataInteractor.getLastValueStatByGraphStatId(graphOrStat.id) == null + } + + override suspend fun duplicateGraphOrStat(graphOrStat: GraphOrStat) { + dataInteractor.duplicateLastValueStat(graphOrStat) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatproviders/datasourceadapters/LineGraphDataSourceAdapter.kt b/app/src/main/java/com/samco/trackandgraph/graphstatproviders/datasourceadapters/LineGraphDataSourceAdapter.kt new file mode 100644 index 000000000..998c8a6f8 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatproviders/datasourceadapters/LineGraphDataSourceAdapter.kt @@ -0,0 +1,54 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatproviders.datasourceadapters + +import com.samco.trackandgraph.base.database.dto.GraphOrStat +import com.samco.trackandgraph.base.database.dto.LineGraphWithFeatures +import com.samco.trackandgraph.base.model.DataInteractor +import javax.inject.Inject + +class LineGraphDataSourceAdapter @Inject constructor( + dataInteractor: DataInteractor +) : GraphStatDataSourceAdapter(dataInteractor) { + override suspend fun writeConfigToDatabase( + graphOrStat: GraphOrStat, + config: LineGraphWithFeatures, + updateMode: Boolean + ) { + if (updateMode) dataInteractor.updateLineGraph(graphOrStat, config) + else dataInteractor.insertLineGraph(graphOrStat, config) + } + + override suspend fun getConfigDataFromDatabase(graphOrStatId: Long): Pair? { + val lineGraph = dataInteractor.getLineGraphByGraphStatId(graphOrStatId) ?: return null + return Pair(lineGraph.id, lineGraph) + } + + override suspend fun shouldPreen(graphOrStat: GraphOrStat): Boolean { + val lineGraph = dataInteractor.getLineGraphByGraphStatId(graphOrStat.id) ?: return true + //If the feature was deleted then it should have been deleted via a cascade rule in the db + // so the any statement should not strictly be necessary. + return lineGraph.features.isEmpty() || lineGraph.features.any { + dataInteractor.getFeatureById(it.featureId) == null + } + } + + override suspend fun duplicateGraphOrStat(graphOrStat: GraphOrStat) { + dataInteractor.duplicateLineGraph(graphOrStat) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatproviders/datasourceadapters/PieChartDataSourceAdapter.kt b/app/src/main/java/com/samco/trackandgraph/graphstatproviders/datasourceadapters/PieChartDataSourceAdapter.kt new file mode 100644 index 000000000..f0ba358ae --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatproviders/datasourceadapters/PieChartDataSourceAdapter.kt @@ -0,0 +1,53 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatproviders.datasourceadapters + +import com.samco.trackandgraph.base.database.dto.GraphOrStat +import com.samco.trackandgraph.base.database.dto.PieChart +import com.samco.trackandgraph.base.model.DataInteractor +import javax.inject.Inject + +class PieChartDataSourceAdapter @Inject constructor( + dataInteractor: DataInteractor +) : GraphStatDataSourceAdapter(dataInteractor) { + override suspend fun writeConfigToDatabase( + graphOrStat: GraphOrStat, + config: PieChart, + updateMode: Boolean + ) { + if (updateMode) dataInteractor.updatePieChart(graphOrStat, config) + else dataInteractor.insertPieChart(graphOrStat, config) + } + + override suspend fun getConfigDataFromDatabase( + graphOrStatId: Long + ): Pair? { + val pieChart = dataInteractor.getPieChartByGraphStatId(graphOrStatId) ?: return null + return Pair(pieChart.id, pieChart) + } + + override suspend fun shouldPreen( + graphOrStat: GraphOrStat + ): Boolean { + return dataInteractor.getPieChartByGraphStatId(graphOrStat.id) == null + } + + override suspend fun duplicateGraphOrStat(graphOrStat: GraphOrStat) { + dataInteractor.duplicatePieChart(graphOrStat) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatproviders/datasourceadapters/TimeHistogramDataSourceAdapter.kt b/app/src/main/java/com/samco/trackandgraph/graphstatproviders/datasourceadapters/TimeHistogramDataSourceAdapter.kt new file mode 100644 index 000000000..732161c2d --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatproviders/datasourceadapters/TimeHistogramDataSourceAdapter.kt @@ -0,0 +1,49 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatproviders.datasourceadapters + +import com.samco.trackandgraph.base.database.dto.GraphOrStat +import com.samco.trackandgraph.base.database.dto.TimeHistogram +import com.samco.trackandgraph.base.model.DataInteractor +import javax.inject.Inject + +class TimeHistogramDataSourceAdapter @Inject constructor( + dataInteractor: DataInteractor +) : GraphStatDataSourceAdapter(dataInteractor) { + override suspend fun writeConfigToDatabase( + graphOrStat: GraphOrStat, + config: TimeHistogram, + updateMode: Boolean + ) { + if (updateMode) dataInteractor.updateTimeHistogram(graphOrStat, config) + else dataInteractor.insertTimeHistogram(graphOrStat, config) + } + + override suspend fun getConfigDataFromDatabase(graphOrStatId: Long): Pair? { + val th = dataInteractor.getTimeHistogramByGraphStatId(graphOrStatId) ?: return null + return Pair(th.id, th) + } + + override suspend fun shouldPreen(graphOrStat: GraphOrStat): Boolean { + return dataInteractor.getTimeHistogramByGraphStatId(graphOrStat.id) == null + } + + override suspend fun duplicateGraphOrStat(graphOrStat: GraphOrStat) { + dataInteractor.duplicateTimeHistogram(graphOrStat) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/GraphStatInitException.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/GraphStatInitException.kt new file mode 100644 index 000000000..e1adc1af0 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/GraphStatInitException.kt @@ -0,0 +1,22 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatview + +import java.lang.Exception + +class GraphStatInitException(val errorTextId: Int) : Exception() diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/exceptions/GraphNotFoundException.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/exceptions/GraphNotFoundException.kt new file mode 100644 index 000000000..f4ff51555 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/exceptions/GraphNotFoundException.kt @@ -0,0 +1,20 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatview.exceptions + +class GraphNotFoundException : Exception() \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/AverageTimeBetweenDataFactory.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/AverageTimeBetweenDataFactory.kt new file mode 100644 index 000000000..9ec49aa3b --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/AverageTimeBetweenDataFactory.kt @@ -0,0 +1,139 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatview.factories + +import com.samco.trackandgraph.base.database.dto.IDataPoint +import com.samco.trackandgraph.base.database.dto.AverageTimeBetweenStat +import com.samco.trackandgraph.base.database.dto.DataPoint +import com.samco.trackandgraph.base.database.dto.GraphOrStat +import com.samco.trackandgraph.base.database.sampling.DataSample +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.functions.functions.DataSampleFunction +import com.samco.trackandgraph.functions.functions.CompositeFunction +import com.samco.trackandgraph.functions.functions.DataClippingFunction +import com.samco.trackandgraph.functions.functions.FilterLabelFunction +import com.samco.trackandgraph.functions.functions.FilterValueFunction +import com.samco.trackandgraph.graphstatview.exceptions.GraphNotFoundException +import com.samco.trackandgraph.graphstatview.factories.viewdto.IAverageTimeBetweenViewData +import com.samco.trackandgraph.graphstatview.factories.viewdto.IGraphStatViewData +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.threeten.bp.Duration +import javax.inject.Inject + +class AverageTimeBetweenDataFactory @Inject constructor( + dataInteractor: DataInteractor, + @IODispatcher ioDispatcher: CoroutineDispatcher +) : ViewDataFactory( + dataInteractor, + ioDispatcher +) { + + companion object { + /** + * Calculates the average duration between the timestamps of a set of data points. This is + * simply the duration between first and last divided by the number of points minus 1. + * Points must be passed in in order from newest to oldest + */ + internal fun calculateAverageTimeBetween( + dataPoints: List + ): Double { + if (dataPoints.size < 2) throw Exception("Don't call this method with less than 2 data points.") + //The data points will be in order newest to oldest + val last = dataPoints.first().timestamp + val first = dataPoints.last().timestamp + return Duration.between(first, last).toMillis() + .toDouble() / (dataPoints.size - 1).toDouble() + } + } + + override suspend fun createViewData( + graphOrStat: GraphOrStat, + onDataSampled: (List) -> Unit + ): IAverageTimeBetweenViewData { + val timeBetweenStat = dataInteractor.getAverageTimeBetweenStatByGraphStatId(graphOrStat.id) + ?: return graphNotFound(graphOrStat) + return createViewData(graphOrStat, timeBetweenStat, onDataSampled) + } + + override suspend fun affectedBy(graphOrStatId: Long, featureId: Long): Boolean { + return dataInteractor.getAverageTimeBetweenStatByGraphStatId(graphOrStatId) + ?.featureId == featureId + } + + override suspend fun createViewData( + graphOrStat: GraphOrStat, + config: AverageTimeBetweenStat, + onDataSampled: (List) -> Unit + ): IAverageTimeBetweenViewData { + return try { + val dataSample = withContext(Dispatchers.IO) { + getRelevantDataPoints(config, config.featureId) + } + val dataPoints = withContext(Dispatchers.IO) { + dataSample.toList() + } + if (dataPoints.size < 2) return notEnoughData(graphOrStat) + val averageMillis = withContext(Dispatchers.Default) { + calculateAverageTimeBetween(dataPoints) + } + onDataSampled(dataSample.getRawDataPoints()) + dataSample.dispose() + object : IAverageTimeBetweenViewData { + override val state = IGraphStatViewData.State.READY + override val graphOrStat = graphOrStat + override val enoughData: Boolean = true + override val averageMillis = averageMillis + } + } catch (throwable: Throwable) { + object : IAverageTimeBetweenViewData { + override val state = IGraphStatViewData.State.ERROR + override val graphOrStat = graphOrStat + override val error = throwable + } + } + } + + private fun graphNotFound(graphOrStat: GraphOrStat) = + object : IAverageTimeBetweenViewData { + override val error = GraphNotFoundException() + override val state = IGraphStatViewData.State.ERROR + override val graphOrStat = graphOrStat + } + + private fun notEnoughData(graphOrStat: GraphOrStat) = + object : IAverageTimeBetweenViewData { + override val state = IGraphStatViewData.State.READY + override val enoughData = false + override val graphOrStat = graphOrStat + } + + private suspend fun getRelevantDataPoints( + config: AverageTimeBetweenStat, + featureId: Long + ): DataSample { + val dataSample = dataInteractor.getDataSampleForFeatureId(featureId) + val filters = mutableListOf() + if (config.filterByLabels) filters.add(FilterLabelFunction(config.labels.toSet())) + if (config.filterByRange) filters.add(FilterValueFunction(config.fromValue, config.toValue)) + filters.add(DataClippingFunction(config.endDate.toOffsetDateTime(), config.sampleSize)) + return CompositeFunction(filters).mapSample(dataSample) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/BarChartDataFactory.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/BarChartDataFactory.kt new file mode 100644 index 000000000..27dd23a17 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/BarChartDataFactory.kt @@ -0,0 +1,316 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.graphstatview.factories + +import com.androidplot.util.SeriesUtils +import com.androidplot.xy.RectRegion +import com.androidplot.xy.SimpleXYSeries +import com.androidplot.xy.StepMode +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.BarChart +import com.samco.trackandgraph.base.database.dto.BarChartBarPeriod +import com.samco.trackandgraph.base.database.dto.DataPoint +import com.samco.trackandgraph.base.database.dto.GraphOrStat +import com.samco.trackandgraph.base.database.dto.YRangeType +import com.samco.trackandgraph.base.database.sampling.DataSample +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.functions.aggregation.GlobalAggregationPreferences +import com.samco.trackandgraph.functions.helpers.TimeHelper +import com.samco.trackandgraph.graphstatview.GraphStatInitException +import com.samco.trackandgraph.graphstatview.factories.viewdto.IBarChartViewData +import com.samco.trackandgraph.graphstatview.factories.viewdto.IGraphStatViewData +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import org.jetbrains.annotations.VisibleForTesting +import org.threeten.bp.Duration +import org.threeten.bp.Period +import org.threeten.bp.ZonedDateTime +import org.threeten.bp.temporal.TemporalAmount +import timber.log.Timber +import javax.inject.Inject +import kotlin.math.abs + +class BarChartDataFactory @Inject constructor( + dataInteractor: DataInteractor, + @IODispatcher ioDispatcher: CoroutineDispatcher +) : ViewDataFactory(dataInteractor, ioDispatcher) { + + companion object { + + @VisibleForTesting + data class BarData( + val bars: List, + val dates: List, + val bounds: RectRegion + ) + + private fun BarChartBarPeriod.asTemporalAmount(): TemporalAmount = when (this) { + BarChartBarPeriod.HOUR -> Duration.ofHours(1) + BarChartBarPeriod.DAY -> Period.ofDays(1) + BarChartBarPeriod.WEEK -> Period.ofWeeks(1) + BarChartBarPeriod.MONTH -> Period.ofMonths(1) + BarChartBarPeriod.THREE_MONTHS -> Period.ofMonths(3) + BarChartBarPeriod.SIX_MONTHS -> Period.ofMonths(6) + BarChartBarPeriod.YEAR -> Period.ofYears(1) + } + + /** + * Calculates the bar data for a bar chart. The output is: + * + * - A list of SimpleXYSeries. Each SimpleXYSeries represents all the bars for a given label. + * the series are sorted by the sum of their values, in descending order. + * + * - A list of ZonedDateTimes. Each ZonedDateTime represents the end of a bar. The dates + * should ascend in order spaced by barSize. The list of bars and dates should always be the + * same size. + * + * - A RectRegion representing the bounds of the data. The x bounds will be 0 to the number of + * bars. The y bounds will be 0 to the max stacked value of any bar or [yTo] if [yRangeType] is [YRangeType.FIXED]. + * + * There should be one bar per [barSize] between [endTime] and [endTime] - [sampleSize]. + * + * All values in every SimpleXYSeries will be multiplied by [scale]. If you use [sumByCount], + * the value multiplied will be the number of data points for a bar rather than the sum of + * their values. + */ + @VisibleForTesting + fun getBarData( + timeHelper: TimeHelper, + dataSample: DataSample, + endTime: ZonedDateTime?, + barSize: BarChartBarPeriod, + sampleSize: TemporalAmount?, + sumByCount: Boolean, + yRangeType: YRangeType, + yTo: Double, + scale: Double + ): BarData { + val barDates = mutableListOf() + val barValuesByLabel = mutableMapOf>() + val iterator = dataSample.iterator() + val barPeriod = barSize.asTemporalAmount() + + //Some variables we will calculate as soon as we have the first data point + // and use to iterate backwards from the end time grouping the data points into bars + var roundedEndTime: ZonedDateTime? = null + var endTimeMinusDuration: ZonedDateTime? = null + var currentBarStartTime: ZonedDateTime? = null + var currentBarEndTime: ZonedDateTime? = null + + //Then count backwards from the last end time to the start time of the duration + while (iterator.hasNext()) { + //Grab the next data point to be placed + val next = iterator.next() + val timestamp = timeHelper.toZonedDateTime(next.timestamp) + + //If we were passed a null end time find the end time of the first data point + // instead + if (roundedEndTime == null) { + roundedEndTime = endTime?.let { + timeHelper.findEndOfTemporal(it, barSize.asTemporalAmount()) + } ?: timeHelper.findEndOfTemporal(timestamp, barSize.asTemporalAmount()) + currentBarStartTime = roundedEndTime.minus(barPeriod) + currentBarEndTime = roundedEndTime + endTimeMinusDuration = sampleSize?.let { roundedEndTime.minus(it) } + + barDates.add(roundedEndTime) + } + + //If the next data point is before the start of the duration we are interested in, we + // can stop + if (endTimeMinusDuration != null && timestamp.isBefore(endTimeMinusDuration)) break + + while (timestamp.isBefore(currentBarStartTime)) { + //we have reached the end of the current bar, so we need to move to the next one + currentBarStartTime = currentBarStartTime!!.minus(barPeriod) + currentBarEndTime = currentBarEndTime!!.minus(barPeriod) + barDates.add(currentBarEndTime) + } + + //Get the list of values for the label of the next data point. If there isn't one, create + // a new one and add it to barValuesByLabel + val values = barValuesByLabel.getOrPut(next.label) { mutableListOf() } + + //If the list is not the same size as barDates, we need to add some zeros to the end + // of all the lists until they're all the same size as barDates + if (values.size < barDates.size) barValuesByLabel.values.forEach { + it.addAll(List(barDates.size - it.size) { 0.0 }) + } + + //Add the value to the double in the list + values[values.size - 1] += if (sumByCount) 1.0 else next.value + } + + //Multiply all values by scale + barValuesByLabel.forEach { (_, u) -> + u.forEachIndexed { index, value -> + u[index] = value * scale + } + } + + val barSumsByLabel = barValuesByLabel + .mapValues { (_, values) -> values.sum() } + .toList() + .sortedByDescending { (_, value) -> value } + + val bars = barValuesByLabel + .map { (label, values) -> + //Reverse the order because the values are added from newest to + // oldest but should be displayed from oldest to newest + SimpleXYSeries( + values.asReversed(), + SimpleXYSeries.ArrayFormat.Y_VALS_ONLY, + label + ) + } + //Sort the layers from largest to smallest so the label with the largest total of + // values is on the bottom + .sortedBy { label -> barSumsByLabel.indexOfFirst { it.first == label.title } } + + // reverse the order because the values are added from newest to + // oldest but should be displayed from oldest to newest + val dates = barDates.asReversed() + + //The values are essentially a grid and we want the largest column sum + val maxY = (0 until (barValuesByLabel.values.firstOrNull()?.size ?: 0)) + .maxOfOrNull { index -> barValuesByLabel.values.sumOf { it[index] } } ?: 0.0 + + //If maxY is 0, we want to show a range of 0 to 1 + val maxYForRange = if (abs(maxY) < 0.0000001) 1.0 else maxY + + val xRegion = SeriesUtils.minMax(listOf(-0.5, (barDates.size - 1) + 0.5)) + val yRegion = SeriesUtils.minMax( + if (yRangeType == YRangeType.FIXED) listOf(0.0, yTo) + else listOf(0.0, maxYForRange) + ) + val bounds = RectRegion(xRegion.min, xRegion.max, yRegion.min, yRegion.max) + + return BarData(bars, dates, bounds) + } + } + + override suspend fun createViewData( + graphOrStat: GraphOrStat, + onDataSampled: (List) -> Unit + ): IBarChartViewData = dataInteractor.getBarChartByGraphStatId(graphOrStat.id)?.let { + createViewData(graphOrStat, it, onDataSampled) + } ?: object : IBarChartViewData { + override val state = IGraphStatViewData.State.ERROR + override val error = GraphStatInitException(R.string.graph_stat_view_not_found) + override val graphOrStat = graphOrStat + } + + override suspend fun affectedBy(graphOrStatId: Long, featureId: Long): Boolean { + return dataInteractor.getBarChartByGraphStatId(graphOrStatId)?.featureId == featureId + } + + private data class BarDataWithYAxisParams( + val bars: List, + val dates: List, + val bounds: RectRegion, + val yAxisParameters: Pair, + ) + + private fun getBarDataWithYAxisParams( + timeHelper: TimeHelper, + dataSample: DataSample, + endTime: ZonedDateTime?, + config: BarChart + ): BarDataWithYAxisParams { + + val barData = getBarData( + timeHelper = timeHelper, + dataSample = dataSample, + endTime = endTime, + barSize = config.barPeriod, + sampleSize = config.sampleSize, + sumByCount = config.sumByCount, + yRangeType = config.yRangeType, + yTo = config.yTo, + scale = config.scale + ) + + val yAxisParameters = DataDisplayIntervalHelper().getYParameters( + barData.bounds.minY.toDouble(), + barData.bounds.maxY.toDouble(), + dataSample.dataSampleProperties.isDuration, + config.yRangeType == YRangeType.FIXED + ) + + val yAxisParameterPair = Pair(yAxisParameters.step_mode, yAxisParameters.n_intervals) + + return BarDataWithYAxisParams( + bars = barData.bars, + dates = barData.dates, + bounds = barData.bounds, + yAxisParameters = yAxisParameterPair + ) + } + + override suspend fun createViewData( + graphOrStat: GraphOrStat, + config: BarChart, + onDataSampled: (List) -> Unit + ): IBarChartViewData { + val dataSample = dataInteractor.getDataSampleForFeatureId(config.featureId) + + if (!dataSample.iterator().hasNext()) return object : IBarChartViewData { + override val state = IGraphStatViewData.State.READY + override val graphOrStat = graphOrStat + } + + try { + //TODO basically everywhere you see zone id i think you might wanna use time helper + // and inject it. + val timeHelper = TimeHelper(GlobalAggregationPreferences) + val endTime = config.endDate.toOffsetDateTime()?.let { timeHelper.toZonedDateTime(it) } + + val barData = withContext(ioDispatcher) { + getBarDataWithYAxisParams( + timeHelper = timeHelper, + dataSample = dataSample, + endTime = endTime, + config = config, + ) + } + + onDataSampled(dataSample.getRawDataPoints()) + + return object : IBarChartViewData { + override val xDates = barData.dates + override val bars = barData.bars + override val durationBasedRange = dataSample.dataSampleProperties.isDuration + override val endTime = endTime ?: barData.dates.last() + override val bounds = barData.bounds + override val yAxisRangeParameters = barData.yAxisParameters + override val state = IGraphStatViewData.State.READY + override val graphOrStat = graphOrStat + override val barPeriod = config.barPeriod.asTemporalAmount() + } + } catch (t: Throwable) { + Timber.d(t, "Error creating bar chart data") + return object : IBarChartViewData { + override val state = IGraphStatViewData.State.ERROR + override val error = GraphStatInitException(R.string.graph_stat_validation_unknown) + override val graphOrStat = graphOrStat + } + } finally { + dataSample.dispose() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/DataDisplayIntervalHelper.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/DataDisplayIntervalHelper.kt new file mode 100644 index 000000000..500fde85e --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/DataDisplayIntervalHelper.kt @@ -0,0 +1,220 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatview.factories + +import com.androidplot.xy.StepMode +import org.jetbrains.annotations.VisibleForTesting +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.log +import kotlin.math.pow +import kotlin.math.round + +class DataDisplayIntervalHelper { + + /** + * Code to calculate nice y-intervals + */ + // There are two possible interval classes. The reason is that we want to make a large amount of + // objects of the first class, which then will get filtered. Then we only have to do the more complex + // calculations for the actually reasonable intervals + private data class PossibleIntervalProto( + val interval: Double, + val preferred: Boolean, + val base: Double + ) + + data class PossibleInterval( + val interval: Double, + val preferred: Boolean, + val n_lines: Int, + val percentage_range_used: Double, + val bounds_min: Double, + val bounds_max: Double, + val base: Double + ) + + data class YAxisParameters( + val step_mode: StepMode, + val n_intervals: Double, + val bounds_min: Double, + val bounds_max: Double + ) + + private fun finishProto( + proto: PossibleIntervalProto, + y_min: Double, + y_max: Double, + fixedBounds: Boolean + ): PossibleInterval? { + return when (fixedBounds) { + true -> finishProtoFixedBounds(proto, y_min, y_max) + false -> finishProtoDynamicBounds(proto, y_min, y_max) + } + } + + private fun finishProtoDynamicBounds( + proto: PossibleIntervalProto, + y_min: Double, + y_max: Double + ): PossibleInterval { + val yRange = y_max - y_min + var boundsMin = floor(y_min / proto.interval) * proto.interval + var boundsMax = ceil(y_max / proto.interval) * proto.interval + + // check whether there is a large gap between bounds and data and if its larger + // than half the base on both sides, remove it + // it can only be this large if the divisor is 1 + val offset = proto.base / 2 + if (y_min - boundsMin >= offset && boundsMax - y_max >= offset) { + boundsMin += offset + boundsMax -= offset + } + + val boundsRange = boundsMax - boundsMin + val nLines = round(boundsRange / proto.interval).toInt() + 1 + val percentageRangeUsed = yRange / boundsRange + + return PossibleInterval( + interval = proto.interval, + preferred = proto.preferred, + n_lines = nLines, + percentage_range_used = percentageRangeUsed, + bounds_min = boundsMin, + bounds_max = boundsMax, + base = proto.base + ) + } + + private fun finishProtoFixedBounds( + proto: PossibleIntervalProto, + yMin: Double, + yMax: Double + ): PossibleInterval? { + val yRange = yMax - yMin + // if we can't evenly divide the y_range we can't do anything, so return a bad dummy interval, which will get filtered out + // this is a little more complex than it should be because of floating point errors + if (yRange.div(proto.interval).rem(1) != 0.0) return null + + return PossibleInterval( + interval = proto.interval, + preferred = proto.preferred, + n_lines = round(yRange / proto.interval).toInt() + 1, + percentage_range_used = 1.0, + bounds_min = yMin, + bounds_max = yMax, + base = proto.base + ) + } + + fun getYParameters( + y_min: Double, + y_max: Double, + time_data: Boolean, + fixedBounds: Boolean, + ): YAxisParameters { + val parameters = getYParametersInternal(y_min, y_max, time_data, fixedBounds) + if (parameters != null) { + return YAxisParameters( + step_mode = StepMode.SUBDIVIDE, + n_intervals = parameters.n_lines.toDouble(), + bounds_min = parameters.bounds_min, + bounds_max = parameters.bounds_max + ) + } + + // fallback if we don't find any solution. Gets used when all our solutions use to little of the range. + // this was the default behavior before this new algorithm existed + return YAxisParameters( + step_mode = StepMode.SUBDIVIDE, + n_intervals = 11.0, + bounds_min = y_min, + bounds_max = y_max + ) + } + + companion object { + private const val MIN_INTERVALS = 6 + private const val MAX_INTERVALS = 12 + private val MIN_USED_RANGE_STEPS = listOf(0.99999, 0.9, 0.8, 0.7) + } + + @VisibleForTesting + fun getYParametersInternal( + yMin: Double, + yMax: Double, + timeData: Boolean, + fixedBounds: Boolean + ): PossibleInterval? { + val yRange = yMax - yMin + + val (base, preferred_divisors, all_divisors) = when (timeData) { + false -> Triple( + 10.0, + setOf(1, 2, 4, 5), + setOf(1, 2, 3, 4, 5, 8) + ) + + true -> Triple( + 60.0, + setOf(1, 2, 3, 4, 6, 12, 24), + setOf(1, 2, 3, 4, 6, 12, 24, 30) + ) + } + + // We want to treat a y_range of 60 the same way as a range of 600 or 6, just times 10 or 0.1 + val normExponent = round(log(yRange, base)) + val normedBase = base.pow(normExponent) + + val reasonableIntervals = all_divisors + .flatMap { div -> + (-1..1).map { exp_offset -> + PossibleIntervalProto( + interval = normedBase * base.pow(exp_offset.toDouble()) / div, + preferred = div in preferred_divisors, + base = normedBase * base.pow(exp_offset.toDouble()) + ) + } + } + // so after we generate a lot of possible interval prototypes, in this first filter we get rid of all the + // ones who just are not plausible at all. We'll do a second check with the exact data further below + .filter { interval -> + ceil(yRange / interval.interval) >= MIN_INTERVALS - 1 && + ceil(yRange / interval.interval) <= MAX_INTERVALS + 1 + } + .mapNotNull { proto -> finishProto(proto, yMin, yMax, fixedBounds = fixedBounds) } + .filter { interval -> interval.n_lines in MIN_INTERVALS..MAX_INTERVALS } + + if (reasonableIntervals.isEmpty()) return null + + for (MIN_USED_RANGE in MIN_USED_RANGE_STEPS) { + // gradually have lower expectations in our output + + for (pref in listOf(true, false)) { + // prefer the 'preferred' intervals when it comes to each used_range step + reasonableIntervals + .filter { it.preferred == pref } + .filter { it.percentage_range_used >= MIN_USED_RANGE } + .sortedByDescending { it.interval } // prefer larger intervals + .forEach { return it } // returns the first element, if there is one + } + } + + return null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/LastValueDataFactory.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/LastValueDataFactory.kt new file mode 100644 index 000000000..92facf7cc --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/LastValueDataFactory.kt @@ -0,0 +1,134 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatview.factories + +import com.samco.trackandgraph.base.database.dto.* +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.functions.functions.CompositeFunction +import com.samco.trackandgraph.functions.functions.DataSampleFunction +import com.samco.trackandgraph.functions.functions.FilterLabelFunction +import com.samco.trackandgraph.functions.functions.FilterValueFunction +import com.samco.trackandgraph.graphstatview.exceptions.GraphNotFoundException +import com.samco.trackandgraph.graphstatview.factories.viewdto.IGraphStatViewData +import com.samco.trackandgraph.graphstatview.factories.viewdto.ILastValueViewData +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject + +class LastValueDataFactory @Inject constructor( + dataInteractor: DataInteractor, + @IODispatcher ioDispatcher: CoroutineDispatcher +) : ViewDataFactory(dataInteractor, ioDispatcher) { + + override suspend fun createViewData( + graphOrStat: GraphOrStat, + onDataSampled: (List) -> Unit + ): ILastValueViewData { + val lastValueStat = dataInteractor.getLastValueStatByGraphStatId(graphOrStat.id) + ?: return graphNotFound(graphOrStat) + return createViewData(graphOrStat, lastValueStat, onDataSampled) + } + + override suspend fun affectedBy(graphOrStatId: Long, featureId: Long): Boolean { + return dataInteractor.getLastValueStatByGraphStatId(graphOrStatId)?.featureId == featureId + } + + override suspend fun createViewData( + graphOrStat: GraphOrStat, + config: LastValueStat, + onDataSampled: (List) -> Unit + ): ILastValueViewData { + return try { + val lastData = getLastDataPoint( + dataInteractor, + config, + onDataSampled + ) ?: return notEnoughData(graphOrStat) + object : ILastValueViewData { + override val state = IGraphStatViewData.State.READY + override val graphOrStat = graphOrStat + override val lastDataPoint = lastData.dataPoint + override val isDuration = lastData.isDuration + } + } catch (throwable: Throwable) { + object : ILastValueViewData { + override val state = IGraphStatViewData.State.ERROR + override val graphOrStat = graphOrStat + override val error = throwable + override val isDuration = false + } + } + } + + private fun graphNotFound(graphOrStat: GraphOrStat) = + object : ILastValueViewData { + override val state = IGraphStatViewData.State.ERROR + override val graphOrStat = graphOrStat + override val error = GraphNotFoundException() + override val isDuration = false + } + + private fun notEnoughData(graphOrStat: GraphOrStat) = + object : ILastValueViewData { + override val state = IGraphStatViewData.State.READY + override val graphOrStat = graphOrStat + override val isDuration = false + } + + private data class LastDataPointData( + val dataPoint: DataPoint, + val isDuration: Boolean + ) + + private suspend fun getLastDataPoint( + dataInteractor: DataInteractor, + config: LastValueStat, + onDataSampled: (List) -> Unit + ): LastDataPointData? { + val dataSample = dataInteractor.getDataSampleForFeatureId(config.featureId) + + val filters = mutableListOf() + if (config.filterByLabels) filters.add(FilterLabelFunction(config.labels.toSet())) + if (config.filterByRange) filters.add(FilterValueFunction(config.fromValue, config.toValue)) + + val sampleFunc = CompositeFunction(filters) + val sample = sampleFunc.mapSample(dataSample) + val firstIDataPoint = sample.firstOrNull() + val rawSample = sample.getRawDataPoints() + val firstRawDataPoint = rawSample.firstOrNull() + val note = if (firstIDataPoint?.timestamp == firstRawDataPoint?.timestamp) { + firstRawDataPoint?.note ?: "" + } else "" + val isDuration = sample.dataSampleProperties.isDuration + + onDataSampled(rawSample) + dataSample.dispose() + return firstIDataPoint?.let { + LastDataPointData( + dataPoint = DataPoint( + timestamp = it.timestamp, + featureId = config.featureId, + value = it.value, + label = it.label, + note = note + ), + isDuration = isDuration + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/LineGraphDataFactory.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/LineGraphDataFactory.kt new file mode 100644 index 000000000..e59483cfd --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/LineGraphDataFactory.kt @@ -0,0 +1,252 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatview.factories + +import com.androidplot.Region +import com.androidplot.util.SeriesUtils +import com.androidplot.xy.FastXYSeries +import com.androidplot.xy.RectRegion +import com.androidplot.xy.StepMode +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.* +import com.samco.trackandgraph.base.database.sampling.DataSample +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.DefaultDispatcher +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.functions.aggregation.GlobalAggregationPreferences +import com.samco.trackandgraph.functions.functions.* +import com.samco.trackandgraph.functions.helpers.TimeHelper +import com.samco.trackandgraph.graphstatview.GraphStatInitException +import com.samco.trackandgraph.graphstatview.factories.viewdto.IGraphStatViewData +import com.samco.trackandgraph.graphstatview.factories.viewdto.ILineGraphViewData +import com.samco.trackandgraph.movingAverageDurations +import com.samco.trackandgraph.plottingModePeriods +import kotlinx.coroutines.* +import org.threeten.bp.Duration +import org.threeten.bp.OffsetDateTime +import javax.inject.Inject +import kotlin.math.abs + +class LineGraphDataFactory @Inject constructor( + dataInteractor: DataInteractor, + @IODispatcher ioDispatcher: CoroutineDispatcher, + @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher +) : ViewDataFactory(dataInteractor, ioDispatcher) { + + override suspend fun createViewData( + graphOrStat: GraphOrStat, + config: LineGraphWithFeatures, + onDataSampled: (List) -> Unit + ): ILineGraphViewData { + try { + val plottableData = generatePlottingData(config, onDataSampled) + val hasPlottableData = plottableData + .seriesPerFeature + .any { kvp -> kvp.value != null } + + val durationBasedRange = config.features + .any { f -> f.durationPlottingMode == DurationPlottingMode.DURATION_IF_POSSIBLE } + val (bounds, yAxisParameters) = getYAxisParameters( + config, + plottableData.seriesPerFeature.values, + durationBasedRange + ) + + return object : ILineGraphViewData { + override val durationBasedRange = durationBasedRange + override val yRangeType = config.yRangeType + override val bounds = bounds + override val hasPlottableData = hasPlottableData + override val endTime = plottableData.endTime + override val plottableData = plottableData.seriesPerFeature + override val state = IGraphStatViewData.State.READY + override val graphOrStat = graphOrStat + override val yAxisRangeParameters = yAxisParameters + } + } catch (throwable: Throwable) { + throwable.printStackTrace() + return object : ILineGraphViewData { + override val state = IGraphStatViewData.State.ERROR + override val graphOrStat = graphOrStat + override val error = throwable + } + } + } + + override suspend fun createViewData( + graphOrStat: GraphOrStat, + onDataSampled: (List) -> Unit + ): ILineGraphViewData { + val lineGraph = dataInteractor.getLineGraphByGraphStatId(graphOrStat.id) + ?: return object : ILineGraphViewData { + override val state = IGraphStatViewData.State.ERROR + override val graphOrStat = graphOrStat + override val error = GraphStatInitException(R.string.graph_stat_view_not_found) + } + return createViewData(graphOrStat, lineGraph, onDataSampled) + } + + override suspend fun affectedBy(graphOrStatId: Long, featureId: Long): Boolean { + return dataInteractor.getLineGraphByGraphStatId(graphOrStatId)?.features + ?.any { it.featureId == featureId } ?: false + } + + private data class PlottingData( + val seriesPerFeature: Map, + val endTime: OffsetDateTime + ) + + private suspend fun generatePlottingData( + lineGraph: LineGraphWithFeatures, + onDataSampled: (List) -> Unit + ): PlottingData = coroutineScope { + withContext(defaultDispatcher) { + //Create all the data samples in parallel (this shouldn't actually take long but why not) + val dataSamples = lineGraph.features.map { lgf -> + async { Pair(lgf, tryGetPlottingData(lineGraph, lgf)) } + }.awaitAll() + + //Get the end time of the graph. If not specified it's the time of the last data + // point of any of the features + val endTime = lineGraph.endDate.toOffsetDateTime() ?: dataSamples + .mapNotNull { it.second.firstOrNull() } + .maxOfOrNull { it.timestamp } + ?: OffsetDateTime.now() + + //Generate the actual plotting data for each sample. This is the part that will take longer + // hence the parallelization + val features = dataSamples.map { pair -> + async { + val clippedSample = DataClippingFunction(endTime, lineGraph.sampleSize) + .mapSample(pair.second) + + //Calling toList on the data sample evaluates it and causes the whole pipeline + // to be processed + val dataPoints = clippedSample.toList().asReversed() + + val series = if (dataPoints.size >= 2) { + getXYSeriesFromDataPoints(dataPoints, endTime, pair.first) + } else null + pair.first to series + } + }.awaitAll().toMap() + + val rawDataPoints = dataSamples + .map { it.second.getRawDataPoints() } + .flatten() + onDataSampled(rawDataPoints) + dataSamples.forEach { it.second.dispose() } + + return@withContext PlottingData( + features, + endTime + ) + } + } + + private suspend fun tryGetPlottingData( + config: LineGraphWithFeatures, + lineGraphFeature: LineGraphFeature + ): DataSample { + val movingAvDuration = movingAverageDurations[lineGraphFeature.averagingMode] + val plottingPeriod = plottingModePeriods[lineGraphFeature.plottingMode] + val rawDataSample = withContext(ioDispatcher) { + dataInteractor.getDataSampleForFeatureId(lineGraphFeature.featureId) + } + + val timeHelper = TimeHelper(GlobalAggregationPreferences) + val aggregationCalculator = when (lineGraphFeature.plottingMode) { + LineGraphPlottingModes.WHEN_TRACKED -> IdentityFunction() + else -> CompositeFunction( + DurationAggregationFunction(timeHelper, plottingPeriod!!), + DataPaddingFunction(timeHelper, config.endDate.toOffsetDateTime(), config.sampleSize) + ) + } + val averageCalculator = when (lineGraphFeature.averagingMode) { + LineGraphAveraginModes.NO_AVERAGING -> IdentityFunction() + else -> MovingAverageFunction(movingAvDuration!!) + } + return CompositeFunction(aggregationCalculator, averageCalculator) + .mapSample(rawDataSample) + } + + private fun getXYSeriesFromDataPoints( + dataSample: List, + endTime: OffsetDateTime, + lineGraphFeature: LineGraphFeature + ): FastXYSeries { + val scale = lineGraphFeature.scale + val offset = lineGraphFeature.offset + val durationDivisor = when (lineGraphFeature.durationPlottingMode) { + DurationPlottingMode.HOURS -> 3600.0 + DurationPlottingMode.MINUTES -> 60.0 + else -> 1.0 + } + + val yValues = dataSample.map { dp -> + (dp.value * scale / durationDivisor) + offset + } + val xValues = dataSample.map { dp -> + Duration.between(endTime, dp.timestamp).toMillis() + } + + var yRegion = SeriesUtils.minMax(yValues) + if (abs(yRegion.min.toDouble() - yRegion.max.toDouble()) < 0.1) + yRegion = Region(yRegion.min, yRegion.min.toDouble() + 0.1) + val xRegion = SeriesUtils.minMax(xValues) + val rectRegion = RectRegion(xRegion.min, xRegion.max, yRegion.min, yRegion.max) + + return object : FastXYSeries { + override fun minMax() = rectRegion + override fun getX(index: Int): Number = xValues[index] + override fun getY(index: Int): Number = yValues[index] + override fun getTitle() = lineGraphFeature.name + override fun size() = xValues.size + } + } + + private fun getYAxisParameters( + lineGraph: LineGraphWithFeatures, + series: Collection, + timeBasedRange: Boolean + ): Pair> { + val fixed = lineGraph.yRangeType == YRangeType.FIXED + + val bounds = RectRegion() + series.forEach { it?.let { bounds.union(it.minMax()) } } + + val (y_min, y_max) = + if (fixed) Pair(lineGraph.yFrom, lineGraph.yTo) + else Pair(bounds.minY, bounds.maxY) + + if (y_min == null || y_max == null) { + return Pair(bounds, Pair(StepMode.SUBDIVIDE, 11.0)) + } + + val parameters = DataDisplayIntervalHelper() + .getYParameters(y_min.toDouble(), y_max.toDouble(), timeBasedRange, fixed) + + bounds.minY = parameters.bounds_min + bounds.maxY = parameters.bounds_max + + val intervalParameters = Pair(parameters.step_mode, parameters.n_intervals) + + return Pair(bounds, intervalParameters) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/PieChartDataFactory.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/PieChartDataFactory.kt new file mode 100644 index 000000000..c77c9cf2f --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/PieChartDataFactory.kt @@ -0,0 +1,118 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatview.factories + +import com.androidplot.pie.Segment +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.IDataPoint +import com.samco.trackandgraph.base.database.dto.DataPoint +import com.samco.trackandgraph.base.database.dto.GraphOrStat +import com.samco.trackandgraph.base.database.dto.PieChart +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.functions.functions.DataClippingFunction +import com.samco.trackandgraph.graphstatview.GraphStatInitException +import com.samco.trackandgraph.graphstatview.factories.viewdto.IPieChartViewData +import com.samco.trackandgraph.graphstatview.factories.viewdto.IGraphStatViewData +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class PieChartDataFactory @Inject constructor( + dataInteractor: DataInteractor, + @IODispatcher ioDispatcher: CoroutineDispatcher +) : ViewDataFactory(dataInteractor, ioDispatcher) { + + override suspend fun createViewData( + graphOrStat: GraphOrStat, + onDataSampled: (List) -> Unit + ): IPieChartViewData { + val pieChart = dataInteractor.getPieChartByGraphStatId(graphOrStat.id) + ?: return object : IPieChartViewData { + override val state = IGraphStatViewData.State.ERROR + override val graphOrStat = graphOrStat + override val error = GraphStatInitException(R.string.graph_stat_view_not_found) + } + return createViewData(graphOrStat, pieChart, onDataSampled) + } + + override suspend fun affectedBy(graphOrStatId: Long, featureId: Long): Boolean { + return dataInteractor.getPieChartByGraphStatId(graphOrStatId)?.featureId == featureId + } + + override suspend fun createViewData( + graphOrStat: GraphOrStat, + config: PieChart, + onDataSampled: (List) -> Unit + ): IPieChartViewData { + return try { + val plottingData = tryGetPlottableDataForPieChart(config, onDataSampled) + ?: return object : IPieChartViewData { + override val state = IGraphStatViewData.State.READY + override val graphOrStat = graphOrStat + } + val segments = getPieChartSegments(plottingData, config.sumByCount) + val total = segments.sumOf { s -> s.value.toDouble() } + val percentages = segments.map { + Segment(it.title, (it.value.toDouble() / total) * 100f) + } + + object : IPieChartViewData { + override val segments = percentages + override val state = IGraphStatViewData.State.READY + override val graphOrStat = graphOrStat + } + } catch (throwable: Throwable) { + object : IPieChartViewData { + override val state = IGraphStatViewData.State.ERROR + override val graphOrStat = graphOrStat + override val error = throwable + } + } + } + + private suspend fun tryGetPlottableDataForPieChart( + pieChart: PieChart, + onDataSampled: (List) -> Unit + ): List? { + val feature = withContext(Dispatchers.IO) { + dataInteractor.getFeatureById(pieChart.featureId) + } ?: return null + val dataSample = DataClippingFunction(pieChart.endDate.toOffsetDateTime(), pieChart.sampleSize) + .mapSample(dataInteractor.getDataSampleForFeatureId(feature.featureId)) + val dataPoints = dataSample.toList() + onDataSampled(dataSample.getRawDataPoints()) + dataSample.dispose() + return dataPoints.ifEmpty { null } + } + + private fun getPieChartSegments(dataSample: List, sumByCount: Boolean) = + dataSample + .groupingBy { dp -> dp.label } + .aggregate { _, accumulator, element, first -> + when { + first && sumByCount -> 1.0 + first && !sumByCount -> element.value + !first && sumByCount -> accumulator!! + 1.0 + else -> accumulator!! + element.value + } + } + .map { b -> Segment(b.key, b.value) } + .sortedBy { s -> s.title } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/TimeHistogramDataFactory.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/TimeHistogramDataFactory.kt new file mode 100644 index 000000000..24d024093 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/TimeHistogramDataFactory.kt @@ -0,0 +1,108 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatview.factories + +import com.samco.trackandgraph.R +import com.samco.trackandgraph.TimeHistogramWindowData +import com.samco.trackandgraph.base.database.dto.DataPoint +import com.samco.trackandgraph.base.database.dto.GraphOrStat +import com.samco.trackandgraph.base.database.dto.TimeHistogram +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.functions.aggregation.GlobalAggregationPreferences +import com.samco.trackandgraph.functions.helpers.TimeHelper +import com.samco.trackandgraph.functions.functions.DataClippingFunction +import com.samco.trackandgraph.graphstatview.GraphStatInitException +import com.samco.trackandgraph.graphstatview.factories.viewdto.IGraphStatViewData +import com.samco.trackandgraph.graphstatview.factories.viewdto.ITimeHistogramViewData +import kotlinx.coroutines.CoroutineDispatcher +import javax.inject.Inject +import kotlin.math.min + +class TimeHistogramDataFactory @Inject constructor( + dataInteractor: DataInteractor, + @IODispatcher ioDispatcher: CoroutineDispatcher +) : ViewDataFactory(dataInteractor, ioDispatcher) { + override suspend fun createViewData( + graphOrStat: GraphOrStat, + onDataSampled: (List) -> Unit + ): ITimeHistogramViewData { + val timeHistogram = dataInteractor.getTimeHistogramByGraphStatId(graphOrStat.id) + ?: return object : ITimeHistogramViewData { + override val state = IGraphStatViewData.State.ERROR + override val graphOrStat = graphOrStat + override val error = GraphStatInitException(R.string.graph_stat_view_not_found) + } + return createViewData(graphOrStat, timeHistogram, onDataSampled) + } + + override suspend fun affectedBy(graphOrStatId: Long, featureId: Long): Boolean { + return dataInteractor.getTimeHistogramByGraphStatId(graphOrStatId)?.featureId == featureId + } + + override suspend fun createViewData( + graphOrStat: GraphOrStat, + config: TimeHistogram, + onDataSampled: (List) -> Unit + ): ITimeHistogramViewData { + return try { + val timeHistogramDataHelper = + TimeHistogramDataHelper(TimeHelper(GlobalAggregationPreferences)) + val barValues = + getBarValues(config, onDataSampled, timeHistogramDataHelper) + val largestBin = timeHistogramDataHelper.getLargestBin(barValues?.map { it.values }) + val maxDisplayHeight = largestBin?.let { + min( + it.times(10.0).toInt().plus(1).div(10.0), + 100.0 + ) + } ?: 100.0 + + object : ITimeHistogramViewData { + override val state = IGraphStatViewData.State.READY + override val graphOrStat = graphOrStat + override val window = TimeHistogramWindowData.getWindowData(config.window) + override val barValues = barValues + override val maxDisplayHeight = maxDisplayHeight + } + } catch (throwable: Throwable) { + object : ITimeHistogramViewData { + override val state = IGraphStatViewData.State.ERROR + override val graphOrStat = graphOrStat + override val error = throwable + } + } + } + + private suspend fun getBarValues( + config: TimeHistogram, + onDataSampled: (List) -> Unit, + timeHistogramDataHelper: TimeHistogramDataHelper + ): List? { + val sample = dataInteractor.getDataSampleForFeatureId(config.featureId) + val dataSample = DataClippingFunction(config.endDate.toOffsetDateTime(), config.sampleSize) + .mapSample(sample) + val barValues = timeHistogramDataHelper + .getHistogramBinsForSample(dataSample, config.window, config.sumByCount) + ?.map { ITimeHistogramViewData.BarValue(it.key, it.value) } + ?.sortedBy { it.label } + onDataSampled(dataSample.getRawDataPoints()) + sample.dispose() + return barValues + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/TimeHistogramDataHelper.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/TimeHistogramDataHelper.kt new file mode 100644 index 000000000..5fe9691f5 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/TimeHistogramDataHelper.kt @@ -0,0 +1,175 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatview.factories + +import com.samco.trackandgraph.TimeHistogramWindowData +import com.samco.trackandgraph.base.database.dto.IDataPoint +import com.samco.trackandgraph.base.database.dto.TimeHistogramWindow +import com.samco.trackandgraph.base.database.sampling.DataSample +import com.samco.trackandgraph.functions.helpers.TimeHelper +import org.threeten.bp.* + +class TimeHistogramDataHelper( + private val timeHelper: TimeHelper +) { + /** + * Given an input list of lists where each list represents the discrete value of a feature and each + * sub-list has the same size and represents the values of each histogram bin for that discrete value. + * Calculate the largest bin by summing the values of each discrete value for each bin and returning + * the largest of those sums. + */ + fun getLargestBin(bins: List>?): Double? { + return bins + ?.getOrElse(0) { null } + ?.size + ?.downTo(1) + ?.maxOfOrNull { index -> bins.sumOf { it[index - 1] } } + } + + internal fun getHistogramBinsForSample( + sample: DataSample, + window: TimeHistogramWindow, + sumByCount: Boolean, + ) = getHistogramBinsForSample(sample, TimeHistogramWindowData.getWindowData(window), sumByCount) + + /** + * This function essentially loops over the data sample and puts every input data point into a bin + * depending on where its timestamp falls within the given input window. For example if the window + * represents a week then each data point may be put into one of 7 bins depending on which day of the + * week it was tracked. + * + * A map is generated with a data structure similar to a Matrix whereby each value in the map is a list of the + * same length. The keys in the map are the labels of the [sample] or just empty string if the sample + * does not have labels. The length of the lists is the number of bins of the given [window]. + * The lists represent the sum of all values in each bin of the histogram + * normalised such that the sum of all values in all lists is 100. + * + * If [sumByCount] is false then the value of each data point is added to the total value of + * the histogram bin it belongs in before normalisation. If sumByCount is true then the value of each + * histogram bin before normalisation is the number of data points that fall in that bin. + * + * [sample] - The data points to generate a histogram for + * [window] - The TimeHistogramWindowData specifying the domain and number of bins of the histogram + * [sumByCount] - Whether this histogram represents the number of data points tracked or + * the sum of their values + */ + internal fun getHistogramBinsForSample( + sample: DataSample, + window: TimeHistogramWindowData, + sumByCount: Boolean, + ): Map>? { + val sampleList = sample.toList() + if (sampleList.isEmpty()) return null + val endTime = getNextEndOfWindow(window, sample.first().timestamp) + + return when { + sumByCount -> getHistogramBinsForSample(sampleList, window, endTime, ::addOneToBin) + else -> getHistogramBinsForSample(sampleList, window, endTime, ::addValueToBin) + } + } + + private fun getNextEndOfWindow( + window: TimeHistogramWindowData, + endDate: OffsetDateTime?, + ): ZonedDateTime { + val end = endDate ?: OffsetDateTime.now() + return timeHelper.findEndOfTemporal(end, window.period) + } + + + private fun getHistogramBinsForSample( + sample: List, + window: TimeHistogramWindowData, + endTime: ZonedDateTime, + addFunction: (IDataPoint, MutableMap>, Int) -> Unit + ): Map> { + val binTotalMaps = calculateBinTotals(sample, window, endTime, addFunction) + val total = binTotalMaps.map { it.value.sum() }.sum() + return binTotalMaps.map { kvp -> kvp.key to kvp.value.map { (it / total) * 100.0 } }.toMap() + } + + /** + * Create a map structure and place every data point in it using the provided addFunction + */ + private fun calculateBinTotals( + sample: List, + window: TimeHistogramWindowData, + endTime: ZonedDateTime, + addFunction: (IDataPoint, MutableMap>, Int) -> Unit + ): Map> { + val binTotalMap = mutableMapOf>() + var currEnd = endTime + var currStart = currEnd - window.period + var binned = 0 + var nextPoint = sample[0] + val timeOf = { dp: IDataPoint -> + //Drop the offset and use the local time. A time of 8:00 should always go in the 8:00 bin + // regardless of what time zone it was tracked in. + dp.timestamp.atZoneSimilarLocal(timeHelper.zoneId) + } + while (binned < sample.size) { + val periodDuration = Duration + .between(currStart, currEnd) + .seconds + .toDouble() + var nextPointTime = timeOf(nextPoint) + while (nextPointTime > currStart) { + val distance = Duration.between( + currStart, + nextPointTime + ).seconds.toDouble() + var binIndex = (window.numBins * (distance / periodDuration)).toInt() + if (binIndex == window.numBins) binIndex-- + if (!binTotalMap.containsKey(nextPoint.label)) { + binTotalMap[nextPoint.label] = MutableList(window.numBins) { 0.0 } + } + addFunction(nextPoint, binTotalMap, binIndex) + if (++binned == sample.size) break + nextPoint = sample[binned] + nextPointTime = timeOf(nextPoint) + } + currEnd -= window.period + currStart -= window.period + } + return binTotalMap + } + + /** + * Add the value of the given data point to the bin at the given binIndex + */ + private fun addValueToBin( + dataPoint: IDataPoint, + bin: MutableMap>, + binIndex: Int + ) { + val list = bin[dataPoint.label] + list?.set(binIndex, list[binIndex] + dataPoint.value) + } + + /** + * Add one to the bin at the given binIndex + */ + private fun addOneToBin( + dataPoint: IDataPoint, + bin: MutableMap>, + binIndex: Int + ) { + val list = bin[dataPoint.label] + list?.set(binIndex, list[binIndex] + 1.0) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/ViewDataFactory.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/ViewDataFactory.kt new file mode 100644 index 000000000..90058c819 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/ViewDataFactory.kt @@ -0,0 +1,97 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatview.factories + +import com.samco.trackandgraph.base.database.dto.DataPoint +import com.samco.trackandgraph.base.database.dto.GraphOrStat +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.graphstatview.factories.viewdto.IGraphStatViewData +import com.samco.trackandgraph.util.Stopwatch +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import timber.log.Timber + +/** + * An abstract factory for generating data for a graph or stat ready to be displayed by an appropriate + * decorator. + * + * I is the store of configuration options that tells the factory how to generate the data + * T is the type of data produced by this factory + */ +abstract class ViewDataFactory( + protected val dataInteractor: DataInteractor, + protected val ioDispatcher: CoroutineDispatcher +) { + + /** + * If the config has been written to the database already it can be retrieved against the + * given graph or stat. This should just get the config and call the other createViewData + * function. + * + * @see createViewData(graphOrStat: GraphOrStat, config: I, onDataSampled: (List) -> Unit) + */ + protected abstract suspend fun createViewData( + graphOrStat: GraphOrStat, + onDataSampled: (List) -> Unit + ): T + + /** + * Reads the given graph or stat from the database and generates the data to be displayed. + * [onDataSampled] will be called at some point with a list of data points that have been + * sampled from the database. No guarantees are made about the sorting or uniqueness of the + * data points. + */ + protected abstract suspend fun createViewData( + graphOrStat: GraphOrStat, + config: I, + onDataSampled: (List) -> Unit + ): T + + /** + * Should return true if any change in the data for the given feature might affect the data + * produced by this factory. + */ + abstract suspend fun affectedBy(graphOrStatId: Long, featureId: Long): Boolean + + @Suppress("UNCHECKED_CAST") + suspend fun getViewData( + graphOrStat: GraphOrStat, + config: Any, + onDataSampled: (List) -> Unit = {} + ): T = timeCreateViewData(graphOrStat) { + createViewData(graphOrStat, config as I, onDataSampled) + } + + suspend fun getViewData( + graphOrStat: GraphOrStat, + onDataSampled: (List) -> Unit = {} + ): T = timeCreateViewData(graphOrStat) { + createViewData(graphOrStat, onDataSampled) + } + + private suspend fun timeCreateViewData( + graphOrStat: GraphOrStat, + createDelegate: suspend () -> T + ): T = withContext(ioDispatcher) { + val stopwatch = Stopwatch().apply { start() } + val viewData = createDelegate() + stopwatch.stop() + Timber.i("Took ${stopwatch.elapsedMillis}ms to generate view data for ${graphOrStat.id}:${graphOrStat.name}") + return@withContext viewData + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/viewdto/IAverageTimeBetweenViewData.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/viewdto/IAverageTimeBetweenViewData.kt new file mode 100644 index 000000000..f9d901a0f --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/viewdto/IAverageTimeBetweenViewData.kt @@ -0,0 +1,38 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatview.factories.viewdto + +import com.samco.trackandgraph.base.database.dto.GraphOrStat + +interface IAverageTimeBetweenViewData : IGraphStatViewData{ + //The total millis between the first and last data point + val averageMillis: Double + get() = 0.0 + + val enoughData: Boolean + get() = false + + companion object { + fun loading(graphOrStat: GraphOrStat) = object : IAverageTimeBetweenViewData { + override val state: IGraphStatViewData.State + get() = IGraphStatViewData.State.LOADING + override val graphOrStat: GraphOrStat + get() = graphOrStat + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/viewdto/IBarChartViewData.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/viewdto/IBarChartViewData.kt new file mode 100644 index 000000000..8e28876a7 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/viewdto/IBarChartViewData.kt @@ -0,0 +1,68 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.graphstatview.factories.viewdto + +import com.androidplot.xy.RectRegion +import com.androidplot.xy.SimpleXYSeries +import com.androidplot.xy.StepMode +import org.threeten.bp.Period +import org.threeten.bp.ZonedDateTime +import org.threeten.bp.temporal.TemporalAmount + +interface IBarChartViewData : IGraphStatViewData { + /** + * One x date for every bar in the list. You don't necessarily draw all of them on the x axis + */ + val xDates: List + get() = emptyList() + + /** + * One bar list for each label in the data set + */ + val bars: List + get() = emptyList() + + /** + * Whether the y values should be interpreted as a number of seconds or just a number + */ + val durationBasedRange: Boolean + get() = false + + /** + * The end time of the graph + */ + val endTime: ZonedDateTime + get() = ZonedDateTime.now() + + /** + * The bounds of the graph in x and y + */ + val bounds: RectRegion + get() = RectRegion() + + /** + * The y axis range parameters used by android plot library + */ + val yAxisRangeParameters: Pair + get() = Pair(StepMode.SUBDIVIDE, 11.0) + + /** + * The period/duration of a single bar + */ + val barPeriod: TemporalAmount + get() = Period.ofDays(1) +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/viewdto/IGraphStatViewData.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/viewdto/IGraphStatViewData.kt new file mode 100644 index 000000000..635ae6fd4 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/viewdto/IGraphStatViewData.kt @@ -0,0 +1,41 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatview.factories.viewdto + +import com.samco.trackandgraph.base.database.dto.GraphOrStat + +interface IGraphStatViewData { + enum class State { + LOADING, + READY, + ERROR + } + val state: State + val graphOrStat: GraphOrStat + val error: Throwable? + get() = null + + companion object { + fun loading(graphOrStat: GraphOrStat) = object: IGraphStatViewData { + override val state: State + get() = State.LOADING + override val graphOrStat: GraphOrStat + get() = graphOrStat + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/viewdto/ILastValueViewData.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/viewdto/ILastValueViewData.kt new file mode 100644 index 000000000..8ede1b871 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/viewdto/ILastValueViewData.kt @@ -0,0 +1,21 @@ +package com.samco.trackandgraph.graphstatview.factories.viewdto + +import com.samco.trackandgraph.base.database.dto.DataPoint +import com.samco.trackandgraph.base.database.dto.GraphOrStat + +interface ILastValueViewData : IGraphStatViewData { + val isDuration: Boolean + val lastDataPoint: DataPoint? + get() = null + + companion object { + fun loading(graphOrStat: GraphOrStat) = object : ILastValueViewData { + override val isDuration: Boolean + get() = false + override val state: IGraphStatViewData.State + get() = IGraphStatViewData.State.LOADING + override val graphOrStat: GraphOrStat + get() = graphOrStat + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/viewdto/ILineGraphViewData.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/viewdto/ILineGraphViewData.kt new file mode 100644 index 000000000..b838dc719 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/viewdto/ILineGraphViewData.kt @@ -0,0 +1,43 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatview.factories.viewdto + +import com.androidplot.xy.FastXYSeries +import com.androidplot.xy.RectRegion +import com.androidplot.xy.StepMode +import com.samco.trackandgraph.base.database.dto.LineGraphFeature +import com.samco.trackandgraph.base.database.dto.YRangeType +import org.threeten.bp.OffsetDateTime + +interface ILineGraphViewData : IGraphStatViewData { + val durationBasedRange: Boolean + get() = false + val yRangeType: YRangeType + get() = YRangeType.DYNAMIC + val bounds: RectRegion + get() = RectRegion() + val hasPlottableData: Boolean + get() = false + val endTime: OffsetDateTime + get() = OffsetDateTime.MIN + val plottableData: Map + get() = emptyMap() + val yAxisRangeParameters: Pair + get() = Pair(StepMode.SUBDIVIDE, 11.0) + +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/viewdto/IPieChartViewData.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/viewdto/IPieChartViewData.kt new file mode 100644 index 000000000..9f82c4cf8 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/viewdto/IPieChartViewData.kt @@ -0,0 +1,35 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatview.factories.viewdto + +import com.androidplot.pie.Segment +import com.samco.trackandgraph.base.database.dto.GraphOrStat + +interface IPieChartViewData : IGraphStatViewData { + val segments: List? + get() = null + + companion object { + fun loading(graphOrStat: GraphOrStat) = object : IPieChartViewData { + override val state: IGraphStatViewData.State + get() = IGraphStatViewData.State.LOADING + override val graphOrStat: GraphOrStat + get() = graphOrStat + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/viewdto/ITimeHistogramViewData.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/viewdto/ITimeHistogramViewData.kt new file mode 100644 index 000000000..2c8fb7064 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/factories/viewdto/ITimeHistogramViewData.kt @@ -0,0 +1,32 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatview.factories.viewdto + +import com.samco.trackandgraph.TimeHistogramWindowData +import com.samco.trackandgraph.base.database.dto.TimeHistogramWindow + +interface ITimeHistogramViewData : IGraphStatViewData { + data class BarValue(val label: String, val values: List) + + val window: TimeHistogramWindowData + get() = TimeHistogramWindowData.getWindowData(TimeHistogramWindow.WEEK) + val barValues: List? + get() = null + val maxDisplayHeight: Double + get() = 0.0 +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/AverageTimeBetweenView.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/AverageTimeBetweenView.kt new file mode 100644 index 000000000..6def747df --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/AverageTimeBetweenView.kt @@ -0,0 +1,58 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatview.ui + +import androidx.compose.foundation.layout.height +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.helpers.formatTimeToDaysHoursMinutesSeconds +import com.samco.trackandgraph.graphstatview.factories.viewdto.IAverageTimeBetweenViewData +import com.samco.trackandgraph.graphstatview.factories.viewdto.IGraphStatViewData + +@Composable +fun AverageTimeBetweenView( + modifier: Modifier = Modifier, + viewData: IAverageTimeBetweenViewData, + graphHeight: Int? = null +) { + if (!viewData.enoughData) { + GraphErrorView( + modifier = modifier, + error = R.string.graph_stat_view_not_enough_data_graph + ) + } else { + Text( + modifier = modifier.let { + if (graphHeight != null) it.height(graphHeight.dp) + else it + }, + text = formatTimeToDaysHoursMinutesSeconds( + context = LocalContext.current, + millis = viewData.averageMillis.toLong() + ), + style = MaterialTheme.typography.h3, + textAlign = TextAlign.Center, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/BarChartView.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/BarChartView.kt new file mode 100644 index 000000000..a9703912c --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/BarChartView.kt @@ -0,0 +1,644 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.graphstatview.ui + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.view.MotionEvent +import androidx.compose.material.Surface +import android.view.View +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidViewBinding +import androidx.core.content.ContextCompat.getColor +import com.androidplot.ui.VerticalPosition +import com.androidplot.ui.VerticalPositioning +import com.androidplot.util.PixelUtils +import com.androidplot.xy.BarFormatter +import com.androidplot.xy.BarRenderer +import com.androidplot.xy.BoundaryMode +import com.androidplot.xy.PanZoom +import com.androidplot.xy.RectRegion +import com.androidplot.xy.SimpleXYSeries +import com.androidplot.xy.StepMode +import com.androidplot.xy.XValueMarker +import com.androidplot.xy.XYGraphWidget +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.helpers.formatDayMonthYearHourMinute +import com.samco.trackandgraph.base.helpers.formatTimeDuration +import com.samco.trackandgraph.base.helpers.getDayMonthFormatter +import com.samco.trackandgraph.base.helpers.getMonthYearFormatter +import com.samco.trackandgraph.databinding.GraphXyPlotBinding +import com.samco.trackandgraph.graphstatview.factories.viewdto.IBarChartViewData +import com.samco.trackandgraph.ui.compose.ui.ColorCircle +import com.samco.trackandgraph.ui.compose.ui.HalfDialogInputSpacing +import com.samco.trackandgraph.ui.compose.ui.DialogInputSpacing +import com.samco.trackandgraph.ui.dataVisColorGenerator +import com.samco.trackandgraph.ui.dataVisColorList +import com.samco.trackandgraph.util.getColorFromAttr +import org.threeten.bp.Duration +import org.threeten.bp.OffsetDateTime +import org.threeten.bp.ZonedDateTime +import org.threeten.bp.format.DateTimeFormatter +import org.threeten.bp.temporal.TemporalAmount +import java.text.FieldPosition +import java.text.Format +import java.text.ParsePosition +import kotlin.math.abs +import kotlin.math.log10 +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +private class BarMarkerStore { + var highlightedIndex by mutableStateOf(null as Int?) + private set + + private val lock = Any() + + fun setHighlightedIndex(index: Int) = synchronized(lock) { + highlightedIndex = index + } + + fun clearHighlightedIndex() = synchronized(lock) { + highlightedIndex = null + } +} + +@Composable +fun BarChartView( + modifier: Modifier = Modifier, + viewData: IBarChartViewData, + listMode: Boolean, + timeMarker: OffsetDateTime? = null, + graphHeight: Int? = null +) = Box(modifier = modifier) { + if (viewData.xDates.isEmpty() || viewData.bars.isEmpty()) { + GraphErrorView( + error = R.string.graph_stat_view_not_enough_data_graph + ) + } else { + val barMarkerStore = remember(timeMarker) { + val barMarkerStore = BarMarkerStore() + timeMarker?.let { marker -> + val zonedMarker = marker.atZoneSameInstant(viewData.endTime.zone) + val index = viewData.xDates.indexOfLast { zonedMarker.isAfter(it) } + 1 + if (index in viewData.xDates.indices) barMarkerStore.setHighlightedIndex(index) + } + barMarkerStore + } + + BarChartBodyView( + xDates = viewData.xDates, + bars = viewData.bars, + durationBasedRange = viewData.durationBasedRange, + endTime = viewData.endTime, + bounds = viewData.bounds, + yAxisRangeParameters = viewData.yAxisRangeParameters, + listMode = listMode, + barMarkerStore = barMarkerStore, + graphHeight = graphHeight + ) + + if (!listMode) barMarkerStore.highlightedIndex?.let { + BarChartDataOverlay( + modifier = Modifier + .wrapContentHeight(Alignment.Top) + .align(Alignment.TopEnd), + context = LocalContext.current, + highlightedIndex = it, + xDates = viewData.xDates, + bars = viewData.bars, + barPeriod = viewData.barPeriod, + viewData.durationBasedRange + ) + } + } +} + +private fun doubleToString(value: Double, maxPlaces: Int = 3): String { + val scale = min(maxPlaces, value.toBigDecimal().scale()) + return String.format("%.${scale}f", value) +} + +@Composable +private fun BarChartDataOverlay( + modifier: Modifier = Modifier, + context: Context, + highlightedIndex: Int, + xDates: List, + bars: List, + barPeriod: TemporalAmount, + durationBasedRange: Boolean +) = Surface( + modifier = modifier + .width(IntrinsicSize.Max) + .animateContentSize() +) { + + val total = remember(highlightedIndex, bars) { + val totalVal = bars.sumOf { it.getyVals()[highlightedIndex].toDouble() } + if (durationBasedRange) formatTimeDuration(totalVal.toLong()) + else doubleToString(totalVal) + } + + val fromText = remember(highlightedIndex, xDates) { + val from = xDates[highlightedIndex].minus(barPeriod) + formatDayMonthYearHourMinute(context, from) + } + + val toText = remember(highlightedIndex, xDates) { + formatDayMonthYearHourMinute(context, xDates[highlightedIndex]) + } + + //a list of label: value (percentage) strings for each label for the current bar + val extraDetails = remember(highlightedIndex, xDates, bars) { + val values = bars.associate { + it.title to it.getyVals()[highlightedIndex].toDouble() + } + val sum = values.values.sum() + + if (sum < 1e-6) emptyList() + else bars.map { + val value = values[it.title] ?: 0.0 + val percentage = (value / sum) * 100.0 + val percentageStr = doubleToString(percentage, 1) + val str = + if (durationBasedRange) formatTimeDuration(value.toLong()) + else doubleToString(value) + return@map "${it.title}: $str ($percentageStr%)" + } + } + + Column( + modifier = Modifier.padding(dimensionResource(id = R.dimen.card_padding)) + ) { + Text( + text = stringResource(id = R.string.from_formatted, fromText), + style = MaterialTheme.typography.body1, + ) + + Text( + text = stringResource(id = R.string.to_formatted, toText), + style = MaterialTheme.typography.body1, + ) + + Text( + text = stringResource(id = R.string.total_formatted, total), + style = MaterialTheme.typography.body1, + ) + + if (extraDetails.isNotEmpty()) BarChartDataOverlayExtraDetails(extraDetails) + } +} + +@Composable +private fun BarChartDataOverlayExtraDetails( + extraDetails: List +) { + var expanded by remember { mutableStateOf(false) } + + DialogInputSpacing() + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded }, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = R.string.info), + style = MaterialTheme.typography.body1, + ) + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + modifier = Modifier + .size(24.dp) + .rotate(if (expanded) 180f else 0f) + ) + } + + + DialogInputSpacing() + + extraDetails.forEachIndexed { index, labelInfo -> + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Row { + val colorIndex = (index * dataVisColorGenerator) % dataVisColorList.size + ColorCircle( + color = dataVisColorList[colorIndex], + size = 16.dp + ) + HalfDialogInputSpacing() + Text( + text = labelInfo, + style = MaterialTheme.typography.body1, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + } +} + +@Composable +private fun BarChartBodyView( + modifier: Modifier = Modifier, + xDates: List, + bars: List, + durationBasedRange: Boolean, + endTime: ZonedDateTime, + bounds: RectRegion, + yAxisRangeParameters: Pair, + listMode: Boolean, + barMarkerStore: BarMarkerStore, + graphHeight: Int? = null +) = Column(modifier = modifier) { + + val context = LocalContext.current + + AndroidViewBinding( + factory = { inflater, parent, attachToParent -> + val binding = GraphXyPlotBinding.inflate(inflater, parent, attachToParent) + + xyPlotSetup( + context = context, + xyPlot = binding.xyPlot + ) + binding.xyPlot.clear() + + setBarChartBounds( + binding = binding, + bounds = bounds, + ) + + val xAxisFormatter = getXAxisFormatter( + context = context, + xDates = xDates, + ) + + setXAxisFormatter( + binding = binding, + xDates = xDates, + xAxisFormatter = xAxisFormatter + ) + + setBarChartPaddingFromBounds( + context = context, + binding = binding, + bounds = bounds, + endTime = endTime, + xAxisFormatter = xAxisFormatter + ) + + setUpXYPlotYAxis( + binding = binding, + yAxisRangeParameters = yAxisRangeParameters, + durationBasedRange = durationBasedRange + ) + drawBars( + context = context, + binding = binding, + bars = bars + ) + + + return@AndroidViewBinding binding + }, + update = { + + setXAxisNumLabels( + context = context, + binding = this, + xDates = xDates, + ) + + if (!listMode) { + attachPanZoomClickListener( + binding = this, + barMarkerStore = barMarkerStore, + barIndexRange = xDates.indices + ) + redrawMarkers( + binding = this, + barMarkerStore = barMarkerStore, + ) + } + + if (graphHeight != null) xyPlot.layoutParams.height = graphHeight + xyPlot.requestLayout() + xyPlot.redraw() + }) + + DialogInputSpacing() + + if (bars.size > 1) { + GraphLegend( + items = bars.mapIndexed { i, bar -> + val colorIndex = (i * dataVisColorGenerator) % dataVisColorList.size + val label = bar.title + .ifEmpty { context.getString(R.string.no_label) } + GraphLegendItem( + color = dataVisColorList[colorIndex], + label = label + ) + } + ) + } +} + +@SuppressLint("ClickableViewAccessibility") +private fun attachPanZoomClickListener( + binding: GraphXyPlotBinding, + barMarkerStore: BarMarkerStore, + barIndexRange: IntRange +) { + val renderer = binding.xyPlot.getRenderer(BarRenderer::class.java) + + binding.xyPlot.setOnTouchListener(object : PanZoom( + binding.xyPlot, + Pan.HORIZONTAL, + Zoom.STRETCH_HORIZONTAL + ) { + + private var down = false + private var pointerX = 0f + private var pointerY = 0f + + override fun zoom(motionEvent: MotionEvent?) { + super.zoom(motionEvent) + redrawMarkers(binding, barMarkerStore) + } + + override fun pan(motionEvent: MotionEvent?) { + super.pan(motionEvent) + redrawMarkers(binding, barMarkerStore) + } + + override fun onTouch(view: View, event: MotionEvent): Boolean { + //Detect a click from a single pointer, otherwise call super + if (event.pointerCount == 1) { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + down = true + pointerX = event.x + pointerY = event.y + return super.onTouch(view, event) + } + + MotionEvent.ACTION_UP -> { + val smallX = abs(event.x - pointerX) < 10 + val smallY = abs(event.y - pointerY) < 10 + if (down && smallX && smallY) onBarClick(event, view) + down = false + } + + else -> { + down = false + return super.onTouch(view, event) + } + } + } else { + down = false + return super.onTouch(view, event) + } + return super.onTouch(view, event) + } + + private fun onBarClick(event: MotionEvent, view: View) { + val rounded = renderer.plot.screenToSeriesX(event.x).toDouble().roundToInt() + if (rounded in barIndexRange) { + toggleBarMarker( + binding = binding, + barMarkerStore = barMarkerStore, + index = rounded + ) + view.performClick() + } + } + }) +} + +fun setXAxisFormatter( + binding: GraphXyPlotBinding, + xDates: List, + xAxisFormatter: DateTimeFormatter +) { + binding.xyPlot.graph.getLineLabelStyle(XYGraphWidget.Edge.BOTTOM).format = + object : Format() { + override fun format( + obj: Any, + toAppendTo: StringBuffer, + pos: FieldPosition + ): StringBuffer { + //We minus a tiny amount to favour labelling the previous bar if we're right on the + // border between two + val number = (obj as Number).toDouble() - 0.0001 + val rounded = number.roundToInt() + //Shouldn't ever happen that we get a date outside of the range, but just in case + val date = + if (rounded in xDates.indices) xDates[rounded] + else if (rounded < 0) xDates.first() + else xDates.last() + + return toAppendTo.append( + if (number < 0) "" + else date.format(xAxisFormatter) + ) + } + + override fun parseObject(source: String, pos: ParsePosition) = null + } +} + +private fun setXAxisNumLabels( + context: Context, + binding: GraphXyPlotBinding, + xDates: List, +) { + val displayMetrics = context.resources.displayMetrics + val dpWidth = binding.xyPlot.width / displayMetrics.density + + if (dpWidth < 0.1f) return + + val maxLabels = (dpWidth / 30.0).toInt().toDouble() + + var xStep = xDates.size + for (i in 1 until (xDates.size / 2)) { + if (xDates.size / i <= maxLabels && xDates.size % i == 0) { + xStep = xDates.size / i + break + } + } + + binding.xyPlot.setDomainStep(StepMode.SUBDIVIDE, min((xStep + 1).toDouble(), maxLabels)) +} + +private fun setBarChartBounds(binding: GraphXyPlotBinding, bounds: RectRegion) { + binding.xyPlot.setRangeBoundaries(bounds.minY, bounds.maxY, BoundaryMode.FIXED) + binding.xyPlot.bounds.set(bounds.minX, bounds.maxX, bounds.minY, bounds.maxY) + binding.xyPlot.outerLimits.set(bounds.minX, bounds.maxX, bounds.minY, bounds.maxY) +} + +private fun setBarChartPaddingFromBounds( + context: Context, + binding: GraphXyPlotBinding, + bounds: RectRegion, + endTime: ZonedDateTime, + xAxisFormatter: DateTimeFormatter +) { + //Set up Y padding + val minY = bounds.minY.toDouble() + val maxY = bounds.maxY.toDouble() + val maxBound = max(abs(minY), abs(maxY)) + val numDigits = log10(maxBound).toFloat() + 3 + binding.xyPlot.graph.paddingLeft = + (numDigits - 1) * (context.resources.displayMetrics.scaledDensity) * 3.5f + + //Set up X padding + val formattedTimestamp = xAxisFormatter.format(endTime) + binding.xyPlot.graph.paddingBottom = + formattedTimestamp.length * (context.resources.displayMetrics.scaledDensity) +} + +private fun getXAxisFormatter( + context: Context, + xDates: List +): DateTimeFormatter { + val minX = xDates.firstOrNull() + val maxX = xDates.lastOrNull() + if (minX == null || maxX == null) return getDayMonthFormatter(context) + val durationRange = Duration.between(minX, maxX) + return when { + durationRange.toMinutes() < 5L -> DateTimeFormatter.ofPattern("HH:mm:ss") + durationRange.toDays() >= 304 -> getMonthYearFormatter(context) + durationRange.toDays() >= 1 -> getDayMonthFormatter(context) + else -> DateTimeFormatter.ofPattern("HH:mm") + } +} + +private fun drawBars( + context: Context, + binding: GraphXyPlotBinding, + bars: List +) { + val outlineColor = context.getColorFromAttr(R.attr.colorOnSurface) + + // if there are more than 60 bars, we don't want to draw the borders + // I chose 60 simply because it's the first round number after the number of weeks in a year + val xfermode = + if (bars.isNotEmpty() && bars[0].getyVals().size < 60) null + else PorterDuffXfermode(PorterDuff.Mode.DST) + + bars.forEachIndexed { i, bv -> + val colorIndex = (i * dataVisColorGenerator) % dataVisColorList.size + val color = getColor(context, dataVisColorList[colorIndex]) + val seriesFormatter = BarFormatter(color, outlineColor) + seriesFormatter.borderPaint.xfermode = xfermode + binding.xyPlot.addSeries(bv, seriesFormatter) + } + + val renderer = binding.xyPlot.getRenderer(BarRenderer::class.java) + renderer.setBarGroupWidth(BarRenderer.BarGroupWidthMode.FIXED_GAP, PixelUtils.dpToPix(0f)) + renderer.barOrientation = BarRenderer.BarOrientation.STACKED +} + +private fun getMarkerPaint(binding: GraphXyPlotBinding): Paint { + //Calculate the marker width + val renderer = binding.xyPlot.getRenderer(BarRenderer::class.java) + //If the gridRect is null it will throw an exception, this can happen if the graph hasn't been + // laid out yet + if (binding.xyPlot.graph.gridRect == null) return Paint() + val width = abs(renderer.plot.seriesToScreenX(1) - renderer.plot.seriesToScreenX(0)) + + val paint = Paint() + paint.color = binding.root.context.getColorFromAttr(R.attr.colorOnSurface) + paint.alpha = (0.2f * 255f).toInt() + paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.OVERLAY) + paint.strokeWidth = width + return paint +} + +private fun toggleBarMarker( + binding: GraphXyPlotBinding, + barMarkerStore: BarMarkerStore, + index: Int +) { + if (barMarkerStore.highlightedIndex == index) barMarkerStore.clearHighlightedIndex() + else barMarkerStore.setHighlightedIndex(index) + redrawMarkers(binding, barMarkerStore) +} + +private fun redrawMarkers( + binding: GraphXyPlotBinding, + barMarkerStore: BarMarkerStore +) { + binding.xyPlot.removeMarkers() + val markerPaint = getMarkerPaint(binding) + barMarkerStore.highlightedIndex?.let { + binding.xyPlot.addMarker( + XValueMarker( + it, + null, + VerticalPosition(0f, VerticalPositioning.ABSOLUTE_FROM_TOP), + markerPaint, + null + ) + ) + } + binding.xyPlot.redraw() +} diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/GraphStatCardView.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/GraphStatCardView.kt new file mode 100644 index 000000000..0493e7c79 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/GraphStatCardView.kt @@ -0,0 +1,175 @@ +package com.samco.trackandgraph.graphstatview.ui + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.Interaction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.samco.trackandgraph.R +import com.samco.trackandgraph.graphstatview.factories.viewdto.* +import com.samco.trackandgraph.group.GraphStatClickListener +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow + + +//This fixes a bug where the card would show a ripple effect when the user long pressed on the card +// but not remove the ripple effect when the user released the long press. I assume this is because +// we are wrapping composable interaction code in recycler view item touch helper code and item +// touch helper does not effectively cancel the interaction when the user goes into long press/dragging +// mode. +@Stable +private class AutoCancelMutableInteractionSourceImpl : MutableInteractionSource { + private val interactionsInput = MutableSharedFlow( + extraBufferCapacity = 16, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + @OptIn(ExperimentalCoroutinesApi::class) + override val interactions: Flow = interactionsInput + .flatMapLatest { + flow { + emit(it) + if (it is PressInteraction.Press) { + delay(1000) + emit(PressInteraction.Release(PressInteraction.Press(Offset(0f, 0f)))) + } + } + } + + + override suspend fun emit(interaction: Interaction) { + interactionsInput.emit(interaction) + } + + override fun tryEmit(interaction: Interaction): Boolean { + return interactionsInput.tryEmit(interaction) + } +} + +@Composable +fun GraphStatCardView( + modifier: Modifier = Modifier, + isElevated: Boolean = false, + graphStatViewData: IGraphStatViewData, + clickListener: GraphStatClickListener? = null, +) = Box( + modifier = Modifier + .padding(dimensionResource(id = R.dimen.card_margin_small)) + .fillMaxWidth() +) { + Card( + modifier = modifier + .fillMaxWidth() + .let { + if (clickListener != null) { + it.clickable( + indication = LocalIndication.current, + interactionSource = AutoCancelMutableInteractionSourceImpl() + ) { clickListener.onClick(graphStatViewData) } + } else it + }, + elevation = if (isElevated) + dimensionResource(id = R.dimen.card_elevation) * 3f + else dimensionResource(R.dimen.card_elevation) + ) { + Box( + modifier = Modifier + .padding(dimensionResource(id = R.dimen.card_padding)) + .fillMaxWidth() + ) { + if (clickListener != null) { + MenuSection( + modifier = Modifier.align(Alignment.TopEnd), + clickListener = clickListener, + graphStatViewData = graphStatViewData + ) + } + + GraphStatView( + graphStatViewData = graphStatViewData, + listMode = true + ) + } + } +} + +@Composable +private fun MenuSection( + modifier: Modifier = Modifier, + clickListener: GraphStatClickListener, + graphStatViewData: IGraphStatViewData +) = Box(modifier = modifier) { + + var expanded by remember { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + + Icon( + modifier = modifier.clickable( + interactionSource = interactionSource, + indication = null + ) { expanded = true }, + painter = painterResource(id = R.drawable.list_menu_icon), + contentDescription = null + ) + + DropdownMenu( + modifier = Modifier + .widthIn(min = 180.dp) + .background(MaterialTheme.colors.background), + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem(onClick = { + clickListener.onDelete(graphStat = graphStatViewData) + expanded = false + }) { + Text( + text = stringResource(id = R.string.delete), + style = MaterialTheme.typography.body1 + ) + } + DropdownMenuItem(onClick = { + clickListener.onEdit(graphStat = graphStatViewData) + expanded = false + }) { + Text( + text = stringResource(id = R.string.edit), + style = MaterialTheme.typography.body1 + ) + } + DropdownMenuItem(onClick = { + clickListener.onMoveGraphStat(graphStat = graphStatViewData) + expanded = false + }) { + Text( + text = stringResource(id = R.string.move_to), + style = MaterialTheme.typography.body1 + ) + } + DropdownMenuItem(onClick = { + clickListener.onDuplicate(graphStat = graphStatViewData) + expanded = false + }) { + Text( + text = stringResource(id = R.string.duplicate), + style = MaterialTheme.typography.body1 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/GraphStatUICommon.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/GraphStatUICommon.kt new file mode 100644 index 000000000..82687058e --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/GraphStatUICommon.kt @@ -0,0 +1,172 @@ +package com.samco.trackandgraph.graphstatview.ui + +import android.content.Context +import android.graphics.Color +import android.view.View +import androidx.annotation.ColorRes +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.androidplot.Plot +import com.androidplot.ui.Anchor +import com.androidplot.ui.HorizontalPositioning +import com.androidplot.ui.VerticalPositioning +import com.androidplot.xy.BoundaryMode +import com.androidplot.xy.StepMode +import com.androidplot.xy.XYGraphWidget +import com.androidplot.xy.XYPlot +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.helpers.formatTimeDuration +import com.samco.trackandgraph.databinding.GraphXyPlotBinding +import com.samco.trackandgraph.ui.compose.ui.ColorCircle +import com.samco.trackandgraph.ui.compose.ui.DialogInputSpacing +import com.samco.trackandgraph.util.getColorFromAttr +import java.text.DecimalFormat +import java.text.FieldPosition +import java.text.Format +import java.text.ParsePosition +import kotlin.math.roundToLong + +fun xyPlotSetup( + context: Context, + xyPlot: XYPlot +) { + xyPlot.layoutManager.remove(xyPlot.legend) + xyPlot.layoutManager.remove(xyPlot.rangeTitle) + xyPlot.layoutManager.remove(xyPlot.title) + xyPlot.domainTitle.position( + 0f, + HorizontalPositioning.ABSOLUTE_FROM_CENTER, + 0f, + VerticalPositioning.ABSOLUTE_FROM_BOTTOM, + Anchor.BOTTOM_MIDDLE + ) + xyPlot.setBorderStyle(Plot.BorderStyle.NONE, null, null) + xyPlot.graph.position( + 0f, + HorizontalPositioning.ABSOLUTE_FROM_LEFT, + 0f, + VerticalPositioning.ABSOLUTE_FROM_TOP + ) + xyPlot.setPlotMargins(0f, 0f, 0f, 0f) + xyPlot.setPlotPadding(0f, 0f, 0f, 0f) + xyPlot.graph.setPadding(0f, 0f, 0f, 10f) + xyPlot.graph.setMargins(0f, 20f, 0f, 0f) + + val colorOnSurface = context.getColorFromAttr(R.attr.colorOnSurface) + + xyPlot.domainTitle.labelPaint.color = colorOnSurface + xyPlot.graph.domainGridLinePaint.color = colorOnSurface + xyPlot.graph.rangeGridLinePaint.color = colorOnSurface + xyPlot.graph.domainSubGridLinePaint.color = colorOnSurface + xyPlot.graph.rangeSubGridLinePaint.color = colorOnSurface + xyPlot.graph.domainOriginLinePaint.color = colorOnSurface + xyPlot.graph.domainOriginLinePaint.strokeWidth = 1f + xyPlot.graph.rangeOriginLinePaint.color = colorOnSurface + xyPlot.graph.rangeOriginLinePaint.strokeWidth = 1f + + //Setting the layer type enables transparent backgrounds, I don't know why + // I discovered it here: https://groups.google.com/g/androidplot/c/5QnJXD0uIIU + xyPlot.setLayerType(View.LAYER_TYPE_SOFTWARE, null) + + xyPlot.graph.gridBackgroundPaint.color = Color.TRANSPARENT + xyPlot.backgroundPaint.color = Color.TRANSPARENT + xyPlot.graph.backgroundPaint.color = Color.TRANSPARENT + + xyPlot.graph.paddingLeft = 0f + xyPlot.graph.paddingBottom = 0f + xyPlot.setRangeBoundaries(0, 1, BoundaryMode.AUTO) + xyPlot.setDomainBoundaries(0, 1, BoundaryMode.GROW) + xyPlot.graph.getLineLabelStyle(XYGraphWidget.Edge.LEFT).format = DecimalFormat("0.0") +} + +fun setUpXYPlotYAxis( + binding: GraphXyPlotBinding, + yAxisRangeParameters: Pair, + durationBasedRange: Boolean +) { + binding.xyPlot.setRangeStep( + yAxisRangeParameters.first, + yAxisRangeParameters.second + ) + if (durationBasedRange) { + binding.xyPlot.graph.getLineLabelStyle(XYGraphWidget.Edge.LEFT).format = + object : Format() { + override fun format( + obj: Any, + toAppendTo: StringBuffer, + pos: FieldPosition + ): StringBuffer { + val sec = (obj as Number).toDouble().roundToLong() + return toAppendTo.append(formatTimeDuration(sec)) + } + + override fun parseObject(source: String, pos: ParsePosition) = null + } + } +} + +@Composable +fun GraphErrorView( + modifier: Modifier = Modifier, + @StringRes error: Int +) = Column(modifier = modifier) { + Text( + modifier = modifier + .padding(vertical = dimensionResource(id = R.dimen.input_spacing_large)), + text = stringResource(error), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center + ) +} + +data class GraphLegendItem( + @ColorRes val color: Int, + val label: String +) + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun GraphLegend( + modifier: Modifier = Modifier, + items: List +) = FlowRow( + modifier = modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center +) { + items.forEach { + GraphLegentItemView(item = it) + DialogInputSpacing() + } +} + +@Composable +fun GraphLegentItemView( + modifier: Modifier = Modifier, + item: GraphLegendItem +) = Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically +) { + + ColorCircle( + color = item.color, + size = 20.dp + ) + + Spacer(modifier = Modifier.width(2.dp)) + + Text( + text = item.label, + style = MaterialTheme.typography.body2 + ) +} diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/GraphStatView.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/GraphStatView.kt new file mode 100644 index 000000000..2ba821afa --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/GraphStatView.kt @@ -0,0 +1,142 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.graphstatview.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.GraphStatType +import com.samco.trackandgraph.graphstatview.GraphStatInitException +import com.samco.trackandgraph.graphstatview.factories.viewdto.IAverageTimeBetweenViewData +import com.samco.trackandgraph.graphstatview.factories.viewdto.IBarChartViewData +import com.samco.trackandgraph.graphstatview.factories.viewdto.IGraphStatViewData +import com.samco.trackandgraph.graphstatview.factories.viewdto.ILastValueViewData +import com.samco.trackandgraph.graphstatview.factories.viewdto.ILineGraphViewData +import com.samco.trackandgraph.graphstatview.factories.viewdto.IPieChartViewData +import com.samco.trackandgraph.graphstatview.factories.viewdto.ITimeHistogramViewData +import com.samco.trackandgraph.ui.compose.ui.DialogInputSpacing +import org.threeten.bp.OffsetDateTime + +@Composable +fun GraphStatView( + modifier: Modifier = Modifier, + graphStatViewData: IGraphStatViewData, + listMode: Boolean, + timeMarker: OffsetDateTime? = null, + graphHeight: Int? = null +) = Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally +) { + Text( + text = graphStatViewData.graphOrStat.name, + style = MaterialTheme.typography.h6, + textAlign = TextAlign.Center + ) + DialogInputSpacing() + when (graphStatViewData.state) { + IGraphStatViewData.State.LOADING -> { + Box( + modifier = Modifier.heightIn(min = guessHeight(graphStatViewData)), + contentAlignment = Alignment.Center + ) { CircularProgressIndicator() } + } + + IGraphStatViewData.State.ERROR -> { + GraphErrorView( + modifier = modifier, + error = (graphStatViewData.error as? GraphStatInitException) + ?.errorTextId + ?: R.string.graph_stat_validation_unknown + ) + } + + else -> GraphStatInnerView( + graphStatViewData = graphStatViewData, + listMode = listMode, + timeMarker = timeMarker, + graphHeight = graphHeight + ) + } +} + +private fun guessHeight(graphStatViewData: IGraphStatViewData): Dp { + return when (graphStatViewData.graphOrStat.type) { + GraphStatType.AVERAGE_TIME_BETWEEN, GraphStatType.LAST_VALUE -> 125.dp + GraphStatType.LINE_GRAPH, GraphStatType.PIE_CHART, GraphStatType.TIME_HISTOGRAM, GraphStatType.BAR_CHART -> 240.dp + } +} + +@Composable +private fun GraphStatInnerView( + graphStatViewData: IGraphStatViewData, + listMode: Boolean, + timeMarker: OffsetDateTime? = null, + graphHeight: Int? = null +) = when (graphStatViewData.graphOrStat.type) { + GraphStatType.LINE_GRAPH -> + LineGraphView( + viewData = graphStatViewData as ILineGraphViewData, + timeMarker = timeMarker, + listMode = listMode, + graphHeight = graphHeight + ) + + GraphStatType.PIE_CHART -> + PieChartView( + viewData = graphStatViewData as IPieChartViewData, + graphHeight = graphHeight + ) + + GraphStatType.AVERAGE_TIME_BETWEEN -> + AverageTimeBetweenView( + viewData = graphStatViewData as IAverageTimeBetweenViewData, + graphHeight = graphHeight + ) + + GraphStatType.LAST_VALUE -> + LastValueStatView( + viewData = graphStatViewData as ILastValueViewData, + listMode = listMode, + graphHeight = graphHeight + ) + + GraphStatType.TIME_HISTOGRAM -> + TimeHistogramView( + viewData = graphStatViewData as ITimeHistogramViewData, + graphHeight = graphHeight + ) + + GraphStatType.BAR_CHART -> + BarChartView( + viewData = graphStatViewData as IBarChartViewData, + timeMarker = timeMarker, + listMode = listMode, + graphHeight = graphHeight + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/LastValueView.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/LastValueView.kt new file mode 100644 index 000000000..986a5182f --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/LastValueView.kt @@ -0,0 +1,145 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatview.ui + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.DataPoint +import com.samco.trackandgraph.base.helpers.formatDayMonthYearHourMinuteWeekDayTwoLines +import com.samco.trackandgraph.base.helpers.formatTimeToDaysHoursMinutesSeconds +import com.samco.trackandgraph.base.helpers.getWeekDayNames +import com.samco.trackandgraph.graphstatview.factories.viewdto.ILastValueViewData +import com.samco.trackandgraph.ui.compose.theming.tngColors +import com.samco.trackandgraph.ui.compose.ui.DataPointValueAndDescription +import com.samco.trackandgraph.ui.compose.ui.InputSpacingLarge +import com.samco.trackandgraph.ui.compose.ui.DialogInputSpacing +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import org.threeten.bp.Duration +import org.threeten.bp.OffsetDateTime + +@Composable +fun LastValueStatView( + modifier: Modifier = Modifier, + viewData: ILastValueViewData, + listMode: Boolean, + graphHeight: Int? = null +) = CompositionLocalProvider( + LocalContentColor provides MaterialTheme.tngColors.onSurface, +) { + viewData.lastDataPoint?.let { + LastValueStatViewBody( + modifier = modifier, + dataPoint = it, + isDuration = viewData.isDuration, + listMode = listMode, + graphHeight = graphHeight + ) + } ?: GraphErrorView( + modifier = modifier, + error = R.string.graph_stat_view_not_enough_data_stat + ) +} + +@Composable +private fun LastValueStatViewBody( + modifier: Modifier = Modifier, + dataPoint: DataPoint, + isDuration: Boolean, + listMode: Boolean, + graphHeight: Int? +) = Column( + modifier = modifier + .padding(dimensionResource(id = R.dimen.card_padding)) + .let { + if (graphHeight != null) it.height(graphHeight.dp) + else it + }, + horizontalAlignment = Alignment.CenterHorizontally +) { + val context = LocalContext.current + + val weekdayNames = getWeekDayNames(context) + + val now = flow { + while (true) { + emit(OffsetDateTime.now()) + delay(1000) + } + }.collectAsStateWithLifecycle(OffsetDateTime.now()).value + + val duration = Duration.between(dataPoint.timestamp, now) + + val durationText = + formatTimeToDaysHoursMinutesSeconds(context, duration.toMillis(), false) + + Text( + text = durationText, + style = MaterialTheme.typography.h4, + textAlign = TextAlign.Center + ) + + DialogInputSpacing() + + Box( + modifier = Modifier + .border( + 1.dp, + MaterialTheme.colors.onSurface.copy(alpha = 0.12f), + MaterialTheme.shapes.small + ) + .alpha(0.8f) + ) { + Row( + modifier = Modifier.padding(dimensionResource(id = R.dimen.card_padding)), + verticalAlignment = Alignment.CenterVertically + ) { + + Text( + text = formatDayMonthYearHourMinuteWeekDayTwoLines( + context, + weekdayNames, + dataPoint.timestamp + ), + style = MaterialTheme.typography.body2, + textAlign = TextAlign.Center, + ) + + InputSpacingLarge() + + DataPointValueAndDescription( + modifier = Modifier.weight(1f), + dataPoint = dataPoint, + isDuration = isDuration, + restrictNoteText = listMode + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/LineGraphView.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/LineGraphView.kt new file mode 100644 index 000000000..b0dfb25c8 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/LineGraphView.kt @@ -0,0 +1,400 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatview.ui + +import android.content.Context +import android.graphics.Paint +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidViewBinding +import androidx.core.content.ContextCompat.getColor +import com.androidplot.ui.VerticalPosition +import com.androidplot.ui.VerticalPositioning +import com.androidplot.xy.* +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.LineGraphFeature +import com.samco.trackandgraph.base.database.dto.LineGraphPointStyle +import com.samco.trackandgraph.base.database.dto.YRangeType +import com.samco.trackandgraph.base.helpers.formatDayMonth +import com.samco.trackandgraph.base.helpers.formatMonthYear +import com.samco.trackandgraph.base.helpers.formatTimeDuration +import com.samco.trackandgraph.databinding.GraphXyPlotBinding +import com.samco.trackandgraph.graphstatview.factories.viewdto.ILineGraphViewData +import com.samco.trackandgraph.ui.dataVisColorList +import com.samco.trackandgraph.util.getColorFromAttr +import org.threeten.bp.Duration +import org.threeten.bp.OffsetDateTime +import org.threeten.bp.ZoneId +import org.threeten.bp.format.DateTimeFormatter +import org.threeten.bp.temporal.Temporal +import java.text.DecimalFormat +import java.text.FieldPosition +import java.text.Format +import java.text.ParsePosition +import kotlin.math.abs +import kotlin.math.log10 +import kotlin.math.max +import kotlin.math.roundToLong + +@Composable +fun LineGraphView( + modifier: Modifier = Modifier, + viewData: ILineGraphViewData, + listMode: Boolean, + timeMarker: OffsetDateTime? = null, + graphHeight: Int? = null +) { + if (!viewData.hasPlottableData) { + GraphErrorView( + modifier = modifier, + error = R.string.graph_stat_view_not_enough_data_graph + ) + } else { + LineGraphBodyView( + modifier = modifier, + viewData = viewData, + timeMarker = timeMarker, + listMode = listMode, + graphHeight = graphHeight + ) + } +} + +@Composable +fun LineGraphBodyView( + modifier: Modifier, + viewData: ILineGraphViewData, + timeMarker: OffsetDateTime? = null, + listMode: Boolean, + graphHeight: Int? = null +) = Column(modifier = modifier) { + + val context = LocalContext.current + + AndroidViewBinding(factory = { inflater, parent, attachToParent -> + val binding = GraphXyPlotBinding.inflate(inflater, parent, attachToParent) + + xyPlotSetup( + context = context, + xyPlot = binding.xyPlot + ) + binding.xyPlot.clear() + + + drawLineGraphFeatures( + context = context, + binding = binding, + plottableData = viewData.plottableData, + listMode = listMode, + ) + setUpLineGraphXAxis( + context = context, + binding = binding, + endTime = viewData.endTime, + ) + setUpXYPlotYAxis( + binding = binding, + yAxisRangeParameters = viewData.yAxisRangeParameters, + durationBasedRange = viewData.durationBasedRange, + ) + setLineGraphBounds( + context = context, + binding = binding, + bounds = viewData.bounds, + yRangeType = viewData.yRangeType, + endTime = viewData.endTime, + listMode = listMode + ) + + if (!listMode) { + PanZoom.attach(binding.xyPlot, PanZoom.Pan.HORIZONTAL, PanZoom.Zoom.STRETCH_HORIZONTAL) + } + + return@AndroidViewBinding binding + }, update = { + setTimeMarker( + context = context, + binding = this, + endTime = viewData.endTime, + timeMarker = timeMarker + ) + + if (graphHeight != null) xyPlot.layoutParams.height = graphHeight + xyPlot.requestLayout() + }) + + GraphLegend( + items = viewData.plottableData.map { + GraphLegendItem( + color = dataVisColorList[it.key.colorIndex], + label = it.key.name + ) + } + ) +} + +private val lineGraphHourMinuteSecondFormat: DateTimeFormatter = DateTimeFormatter + .ofPattern("HH:mm:ss") +private val lineGraphHoursDateFormat: DateTimeFormatter = DateTimeFormatter + .ofPattern("HH:mm") + +private fun setLineGraphBounds( + context: Context, + binding: GraphXyPlotBinding, + bounds: RectRegion, + yRangeType: YRangeType, + endTime: OffsetDateTime, + listMode: Boolean +) { + // since we now calculate the bounds to fit the number of intervals we almost always want + // to set the rangeBoundaries to the bounds. + // The only exception is when the graph is viewed fullscreen-mode (listMode == False) while dynamic + if (yRangeType == YRangeType.FIXED || listMode) { + binding.xyPlot.setRangeBoundaries(bounds.minY, bounds.maxY, BoundaryMode.FIXED) + } + binding.xyPlot.bounds.set(bounds.minX, bounds.maxX, bounds.minY, bounds.maxY) + binding.xyPlot.outerLimits.set(bounds.minX, bounds.maxX, bounds.minY, bounds.maxY) + setLineGraphPaddingFromBounds( + context = context, + binding = binding, + bounds = bounds, + endTime = endTime, + ) +} + +private fun setLineGraphPaddingFromBounds( + context: Context, + binding: GraphXyPlotBinding, + bounds: RectRegion, + endTime: OffsetDateTime +) { + //Set up Y padding + val minY = bounds.minY.toDouble() + val maxY = bounds.maxY.toDouble() + val maxBound = max(abs(minY), abs(maxY)) + val numDigits = log10(maxBound).toFloat() + 3 + binding.xyPlot.graph.paddingLeft = + (numDigits - 1) * (context.resources.displayMetrics.scaledDensity) * 3.5f + + //Set up X padding + val formattedTimestamp = getDateTimeFormattedForDuration( + context = context, + binding = binding, + endTime = endTime, + ) + binding.xyPlot.graph.paddingBottom = + formattedTimestamp.length * (context.resources.displayMetrics.scaledDensity) +} + +private fun setUpLineGraphXAxis( + context: Context, + binding: GraphXyPlotBinding, + endTime: OffsetDateTime +) { + binding.xyPlot.domainTitle.text = "" + binding.xyPlot.setDomainStep(StepMode.SUBDIVIDE, 11.0) + binding.xyPlot.graph.getLineLabelStyle(XYGraphWidget.Edge.BOTTOM).format = + object : Format() { + override fun format( + obj: Any, + toAppendTo: StringBuffer, + pos: FieldPosition + ): StringBuffer { + val millis = (obj as Number).toLong() + val duration = Duration.ofMillis(millis) + + val formattedTimestamp = getDateTimeFormattedForDuration( + context, + binding, + duration, + endTime + ) + return toAppendTo.append(formattedTimestamp) + } + + override fun parseObject(source: String, pos: ParsePosition) = null + } +} + +private fun getDateTimeFormattedForDuration( + context: Context, + binding: GraphXyPlotBinding, + duration: Duration = Duration.ZERO, + endTime: OffsetDateTime +): String { + val timestamp = endTime + .atZoneSameInstant(ZoneId.systemDefault()) + .plus(duration) + val minX = binding.xyPlot.bounds.minX + val maxX = binding.xyPlot.bounds.maxX + if (minX == null || maxX == null) return formatDayMonth(context, timestamp) + val durationRange = Duration.ofMillis(abs(maxX.toLong() - minX.toLong())) + return when { + durationRange.toMinutes() < 5L -> lineGraphHourMinuteSecondFormat.format(timestamp) + durationRange.toDays() >= 304 -> formatMonthYear(context, timestamp) + durationRange.toDays() >= 1 -> formatDayMonth(context, timestamp) + else -> lineGraphHoursDateFormat.format(timestamp) + } +} + +private fun drawLineGraphFeatures( + context: Context, + binding: GraphXyPlotBinding, + plottableData: Map, + listMode: Boolean +) { + for (kvp in plottableData) { + kvp.value?.let { + addSeries( + context = context, + binding = binding, + series = it, + lineGraphFeature = kvp.key, + listMode = listMode + ) + } + } +} + +private fun addSeries( + context: Context, + binding: GraphXyPlotBinding, + series: FastXYSeries, + lineGraphFeature: LineGraphFeature, + listMode: Boolean +) { + val seriesFormat = + if (listMode && lineGraphFeature.pointStyle != LineGraphPointStyle.CIRCLES_AND_NUMBERS) + getFastLineAndPointFormatter(context, lineGraphFeature) + else getLineAndPointFormatter(context, lineGraphFeature) + binding.xyPlot.addSeries(series, seriesFormat) +} + +private fun getLineAndPointFormatter( + context: Context, + lineGraphFeature: LineGraphFeature +): LineAndPointFormatter { + val formatter = LineAndPointFormatter() + formatter.linePaint.apply { + color = getLinePaintColor(context, lineGraphFeature) + strokeWidth = getLinePaintWidth(context) + } + getVertexPaintColor(context, lineGraphFeature)?.let { + formatter.vertexPaint.color = it + formatter.vertexPaint.strokeWidth = getVertexPaintWidth(context) + } ?: run { + formatter.vertexPaint = null + } + getPointLabelFormatter(context, lineGraphFeature)?.let { + formatter.pointLabelFormatter = it + formatter.setPointLabeler { series, index -> + DecimalFormat("#.#").format(series.getY(index)) + } + } ?: run { + formatter.pointLabelFormatter = null + } + formatter.fillPaint = null + return formatter +} + +private fun getFastLineAndPointFormatter( + context: Context, + lineGraphFeature: LineGraphFeature +): LineAndPointFormatter { + val formatter = FastLineAndPointRenderer.Formatter( + getLinePaintColor(context, lineGraphFeature), + getVertexPaintColor(context, lineGraphFeature), + getPointLabelFormatter(context, lineGraphFeature) + ) + formatter.linePaint?.apply { isAntiAlias = false } + formatter.linePaint?.apply { strokeWidth = getLinePaintWidth(context) } + formatter.vertexPaint?.apply { strokeWidth = getVertexPaintWidth(context) } + return formatter +} + +private fun getLinePaintWidth( + context: Context +) = context.resources.getDimension(R.dimen.line_graph_line_thickness) + +private fun getVertexPaintWidth( + context: Context +) = context.resources.getDimension(R.dimen.line_graph_vertex_thickness) + +private fun getLinePaintColor(context: Context, lineGraphFeature: LineGraphFeature): Int { + return getPaintColor(context, lineGraphFeature) +} + +private fun getVertexPaintColor(context: Context, lineGraphFeature: LineGraphFeature): Int? { + return if (lineGraphFeature.pointStyle == LineGraphPointStyle.NONE) null + else getPaintColor(context, lineGraphFeature) +} + +private fun getPointLabelFormatter( + context: Context, + lineGraphFeature: LineGraphFeature +): PointLabelFormatter? { + if (lineGraphFeature.pointStyle != LineGraphPointStyle.CIRCLES_AND_NUMBERS) return null + val color = context.getColorFromAttr(android.R.attr.textColorPrimary) + val pointLabelFormatter = PointLabelFormatter( + color, + context.resources.getDimension(R.dimen.line_graph_point_label_h_offset), + context.resources.getDimension(R.dimen.line_graph_point_label_v_offset) + ) + pointLabelFormatter.textPaint.textAlign = Paint.Align.RIGHT + return pointLabelFormatter +} + +private fun getPaintColor( + context: Context, + lineGraphFeature: LineGraphFeature +) = getColor(context, dataVisColorList[lineGraphFeature.colorIndex]) + +private fun getMarkerPaint( + context: Context +): Paint { + val color = context.getColorFromAttr(R.attr.errorTextColor) + val paint = Paint() + paint.color = color + paint.strokeWidth = 2f + return paint +} + +private fun setTimeMarker( + context: Context, + binding: GraphXyPlotBinding, + endTime: Temporal, + timeMarker: OffsetDateTime? +) { + binding.xyPlot.removeMarkers() + if (timeMarker == null) return + + val markerPaint = getMarkerPaint(context) + val millis = Duration.between(endTime, timeMarker).toMillis() + binding.xyPlot.addMarker( + XValueMarker( + millis, + null, + VerticalPosition(0f, VerticalPositioning.ABSOLUTE_FROM_TOP), + markerPaint, + null + ) + ) + binding.xyPlot.redraw() +} diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/PieChartView.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/PieChartView.kt new file mode 100644 index 000000000..b6f0ca6de --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/PieChartView.kt @@ -0,0 +1,136 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatview.ui + +import android.graphics.Color +import androidx.annotation.ColorRes +import androidx.compose.foundation.layout.Column +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.viewinterop.AndroidViewBinding +import com.androidplot.pie.PieRenderer +import com.androidplot.pie.Segment +import com.androidplot.pie.SegmentFormatter +import com.samco.trackandgraph.R +import com.samco.trackandgraph.databinding.GraphPieChartBinding +import com.samco.trackandgraph.graphstatview.factories.viewdto.IPieChartViewData +import com.samco.trackandgraph.ui.dataVisColorGenerator +import com.samco.trackandgraph.ui.dataVisColorList + +private data class SegmentInfo( + @ColorRes val color: Int, + val segment: Segment, + val label: String +) + +@Composable +fun PieChartView( + modifier: Modifier = Modifier, + viewData: IPieChartViewData, + graphHeight: Int? = null +) { + if (viewData.segments.isNullOrEmpty()) { + GraphErrorView( + modifier = modifier, + error = R.string.graph_stat_view_not_enough_data_graph + ) + } else { + val segCount = viewData.segments!!.size + val segments = viewData.segments!!.mapIndexed { i, s -> + val index = (dataVisColorGenerator * i) % dataVisColorList.size + val color = dataVisColorList[index] + val percentage = "%.1f".format(s.value.toDouble()) + val title = s.title.ifEmpty { stringResource(R.string.no_label) } + var label = "$title ($percentage%)" + + if (segCount > dataVisColorList.size) { + label = "$i: $title ($percentage%)" + s.title = i.toString() + } + + SegmentInfo( + color = color, + segment = s, + label = label + ) + } + + PieChartViewBody( + modifier = modifier, + segments = segments, + graphHeight = graphHeight + ) + } + +} + +@Composable +private fun PieChartViewBody( + modifier: Modifier = Modifier, + segments: List, + graphHeight: Int? +) = Column(modifier = modifier) { + + val context = LocalContext.current + + val smallLabelSize = context.resources.getDimension(R.dimen.small_label_size) + val labelColor = colorResource(id = R.color.white).toArgb() + + AndroidViewBinding(factory = { inflater, parent, attachToParent -> + val binding = GraphPieChartBinding.inflate(inflater, parent, attachToParent) + + binding.pieChart.clear() + binding.pieChart.backgroundPaint.color = Color.TRANSPARENT + + segments.forEachIndexed { i, s -> + + val segForm = SegmentFormatter(context.getColor(s.color)) + if (segments.size > dataVisColorList.size) { + segForm.labelPaint.textSize = smallLabelSize + segForm.labelPaint.color = labelColor + } else { + segForm.labelPaint.color = Color.TRANSPARENT + } + + binding.pieChart.addSegment(s.segment, segForm) + } + + return@AndroidViewBinding binding + }, update = { + + if (graphHeight != null) pieChart.layoutParams.height = graphHeight + pieChart.redraw() + pieChart.getRenderer(PieRenderer::class.java) + .setDonutSize(0f, PieRenderer.DonutMode.PERCENT) + pieChart.requestLayout() + }) + + GraphLegend( + items = segments.map { + val label = it.label + .ifEmpty { context.getString(R.string.no_label) } + GraphLegendItem(color = it.color, label = label) + } + ) +} diff --git a/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/TimeHistogramView.kt b/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/TimeHistogramView.kt new file mode 100644 index 000000000..636ea7107 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/graphstatview/ui/TimeHistogramView.kt @@ -0,0 +1,299 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.graphstatview.ui + +import android.content.Context +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidViewBinding +import androidx.core.content.ContextCompat.getColor +import com.androidplot.util.PixelUtils +import com.androidplot.xy.* +import com.samco.trackandgraph.R +import com.samco.trackandgraph.TimeHistogramWindowData +import com.samco.trackandgraph.base.database.dto.TimeHistogramWindow +import com.samco.trackandgraph.databinding.GraphXyPlotBinding +import com.samco.trackandgraph.graphstatview.factories.viewdto.ITimeHistogramViewData +import com.samco.trackandgraph.ui.compose.ui.DialogInputSpacing +import com.samco.trackandgraph.ui.dataVisColorGenerator +import com.samco.trackandgraph.ui.dataVisColorList +import com.samco.trackandgraph.util.getColorFromAttr +import org.threeten.bp.DayOfWeek +import org.threeten.bp.temporal.WeekFields +import java.text.FieldPosition +import java.text.Format +import java.text.ParsePosition +import java.util.* +import kotlin.math.max +import kotlin.math.roundToInt + +@Composable +fun TimeHistogramView( + modifier: Modifier = Modifier, + viewData: ITimeHistogramViewData, + graphHeight: Int? = null +) { + if (viewData.barValues.isNullOrEmpty()) { + GraphErrorView( + modifier = modifier, + error = R.string.graph_stat_view_not_enough_data_graph + ) + } else { + TimeHistogramBodyView( + modifier = modifier, + window = viewData.window, + barValues = viewData.barValues!!, + maxDisplayHeight = viewData.maxDisplayHeight, + graphHeight = graphHeight + ) + } +} + +@Composable +private fun TimeHistogramBodyView( + modifier: Modifier, + window: TimeHistogramWindowData, + barValues: List, + maxDisplayHeight: Double, + graphHeight: Int? +) = Column(modifier = modifier) { + + val context = LocalContext.current + + AndroidViewBinding( + factory = { inflater, parent, attachToParent -> + val binding = GraphXyPlotBinding.inflate(inflater, parent, attachToParent) + + xyPlotSetup( + context = context, + xyPlot = binding.xyPlot + ) + binding.xyPlot.clear() + + setUpXAxis( + binding = binding, + window = window + ) + setUpXAxisTitle( + context = context, + binding = binding, + window = window + ) + setUpYAxis( + context = context, + binding = binding, + maxDisplayHeight = maxDisplayHeight + ) + setUpBounds( + binding = binding, + window = window, + maxDisplayHeight = maxDisplayHeight + ) + drawBars( + context = context, + binding = binding, + barValues = barValues + ) + + return@AndroidViewBinding binding + }, + update = { + if (graphHeight != null) xyPlot.layoutParams.height = graphHeight + xyPlot.requestLayout() + }) + + DialogInputSpacing() + + if (barValues.size > 1) { + GraphLegend( + items = barValues.mapIndexed { i, bar -> + val colorIndex = (i * dataVisColorGenerator) % dataVisColorList.size + val label = bar.label + .ifEmpty { context.getString(R.string.no_label) } + GraphLegendItem( + color = dataVisColorList[colorIndex], + label = label + ) + } + ) + } +} + +private fun setUpXAxisTitle( + context: Context, + binding: GraphXyPlotBinding, + window: TimeHistogramWindowData +) { + var title = getNameForWindow(context, window) + if (window.window == TimeHistogramWindow.WEEK) { + val weekDayNameIds = mapOf( + DayOfWeek.MONDAY to R.string.mon, + DayOfWeek.TUESDAY to R.string.tue, + DayOfWeek.WEDNESDAY to R.string.wed, + DayOfWeek.THURSDAY to R.string.thu, + DayOfWeek.FRIDAY to R.string.fri, + DayOfWeek.SATURDAY to R.string.sat, + DayOfWeek.SUNDAY to R.string.sun + ) + val firstDay = WeekFields.of(Locale.getDefault()).firstDayOfWeek + val firstDayName = context.getString(weekDayNameIds[firstDay] ?: error("")) + var lastDayIndex = DayOfWeek.values().indexOf(firstDay) - 1 + if (lastDayIndex < 0) lastDayIndex = DayOfWeek.values().size - 1 + val lastDay = DayOfWeek.values()[lastDayIndex] + val lastDayName = context.getString(weekDayNameIds[lastDay] ?: error("")) + title += " ($firstDayName-$lastDayName)" + } + binding.xyPlot.domainTitle.text = title +} + +private fun setUpYAxis( + context: Context, + binding: GraphXyPlotBinding, + maxDisplayHeight: Double +) { + val divisions = (maxDisplayHeight / 10) + 1 + binding.xyPlot.setRangeStep(StepMode.SUBDIVIDE, max(2.0, divisions)) + binding.xyPlot.graph.paddingLeft = context.resources.displayMetrics.scaledDensity * 8f + binding.xyPlot.graph.getLineLabelStyle(XYGraphWidget.Edge.LEFT).format = + object : Format() { + override fun format( + obj: Any, + toAppendTo: StringBuffer, + pos: FieldPosition + ): StringBuffer { + val percent = (obj as Number).toDouble() + return toAppendTo.append(atMost1dp(percent) + "%") + } + + override fun parseObject(source: String, pos: ParsePosition) = null + } +} + +private fun atMost1dp(value: Double): String { + return if (value % 1 == 0.0) String.format("%.0f", value) + else String.format("%.1f", value) +} + +private fun setUpXAxis( + binding: GraphXyPlotBinding, + window: TimeHistogramWindowData +) { + binding.xyPlot.setDomainStep(StepMode.SUBDIVIDE, window.numBins + 2.0) + binding.xyPlot.graph.getLineLabelStyle(XYGraphWidget.Edge.BOTTOM).format = + object : Format() { + override fun format( + obj: Any, + toAppendTo: StringBuffer, + pos: FieldPosition + ): StringBuffer { + val zeroIndexOffset = + // The bins we get start at index 0. The bin index can represent minutes, hours, + // day of the week or day of the month, etc. + // Since there is a hour 0 and a minute 0, but not a day or week 0 we have + // to add an offset of 1 to the labels if talking about days or weeks. + if (window.window == TimeHistogramWindow.DAY + || window.window == TimeHistogramWindow.HOUR + ) 0 // there is a minute 0 and a hour 0: index 0 -> label 0 + else 1 // but there is no day 0 or week 0: index 0 -> label 1 + + val index = (obj as Double).roundToInt() + zeroIndexOffset + val str = if (index >= zeroIndexOffset && index <= window.numBins) { + val labelInterval = getLabelInterval(window.window) + if (index == zeroIndexOffset + || index == window.numBins + || index % labelInterval == 0 + ) index.toString() + else "" + } else "" + return toAppendTo.append(str) + } + + override fun parseObject(source: String, pos: ParsePosition) = null + } +} + +private fun setUpBounds( + binding: GraphXyPlotBinding, + window: TimeHistogramWindowData, + maxDisplayHeight: Double +) { + binding.xyPlot.setRangeBoundaries( + 0, + maxDisplayHeight, + BoundaryMode.FIXED + ) + binding.xyPlot.bounds.set( + -1.0, + window.numBins, + 0.0, + maxDisplayHeight + ) + binding.xyPlot.outerLimits.set( + -1.0, + window.numBins, + 0.0, + maxDisplayHeight + ) +} + +private fun drawBars( + context: Context, + binding: GraphXyPlotBinding, + barValues: List +) { + val outlineColor = context.getColorFromAttr(R.attr.colorOnSurface) + + barValues.forEachIndexed { i, bv -> + val series = (barValues[i]).values + val xySeries = SimpleXYSeries(series, SimpleXYSeries.ArrayFormat.Y_VALS_ONLY, bv.label) + val colorIndex = (i * dataVisColorGenerator) % dataVisColorList.size + val color = getColor(context, dataVisColorList[colorIndex]) + val seriesFormatter = BarFormatter(color, outlineColor) + seriesFormatter.borderPaint.strokeWidth = PixelUtils.dpToPix(1f) + binding.xyPlot.addSeries(xySeries, seriesFormatter) + } + + val renderer = binding.xyPlot.getRenderer(BarRenderer::class.java) + renderer.setBarGroupWidth(BarRenderer.BarGroupWidthMode.FIXED_GAP, PixelUtils.dpToPix(0f)) + renderer.barOrientation = BarRenderer.BarOrientation.STACKED +} + +private fun getNameForWindow( + context: Context, + window: TimeHistogramWindowData +): String { + return when (window.window) { + TimeHistogramWindow.HOUR -> context.getString(R.string.minutes) + TimeHistogramWindow.DAY -> context.getString(R.string.hours) + TimeHistogramWindow.WEEK -> context.getString(R.string.days) + TimeHistogramWindow.MONTH -> context.getString(R.string.days) + TimeHistogramWindow.THREE_MONTHS -> context.getString(R.string.weeks) + TimeHistogramWindow.SIX_MONTHS -> context.getString(R.string.weeks) + TimeHistogramWindow.YEAR -> context.getString(R.string.months) + } +} + +private fun getLabelInterval(window: TimeHistogramWindow) = when (window) { + TimeHistogramWindow.HOUR -> 5 + TimeHistogramWindow.DAY -> 2 + TimeHistogramWindow.MONTH -> 5 + TimeHistogramWindow.SIX_MONTHS -> 2 + else -> 1 +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/group/CalculatedGraphViewData.kt b/app/src/main/java/com/samco/trackandgraph/group/CalculatedGraphViewData.kt new file mode 100644 index 000000000..97382e200 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/group/CalculatedGraphViewData.kt @@ -0,0 +1,8 @@ +package com.samco.trackandgraph.group + +import com.samco.trackandgraph.graphstatview.factories.viewdto.IGraphStatViewData + +class CalculatedGraphViewData( + val time: Long, + val viewData: IGraphStatViewData +) \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/group/DragTouchHelperCallback.kt b/app/src/main/java/com/samco/trackandgraph/group/DragTouchHelperCallback.kt new file mode 100644 index 000000000..dd307b0b1 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/group/DragTouchHelperCallback.kt @@ -0,0 +1,60 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.group + +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView + +class DragTouchHelperCallback( + private val onMove: (startPos: Int, endPos: Int) -> Unit, + private val onUpdateDisplayIndexes: () -> Unit +) : ItemTouchHelper.Callback() { + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + return makeFlag( + ItemTouchHelper.ACTION_STATE_DRAG, + ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT + ) + } + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + onMove(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition) + return true + } + + override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { + super.onSelectedChanged(viewHolder, actionState) + if (viewHolder != null && viewHolder is GroupChildViewHolder && actionState == ItemTouchHelper.ACTION_STATE_DRAG) { + viewHolder.elevateCard() + } + } + + override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + super.clearView(recyclerView, viewHolder) + (viewHolder as GroupChildViewHolder).dropCard() + onUpdateDisplayIndexes() + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/group/GraphStatViewHolder.kt b/app/src/main/java/com/samco/trackandgraph/group/GraphStatViewHolder.kt new file mode 100644 index 000000000..60692325d --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/group/GraphStatViewHolder.kt @@ -0,0 +1,84 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.group + +import androidx.compose.runtime.* +import androidx.compose.ui.platform.ComposeView +import com.samco.trackandgraph.graphstatview.factories.viewdto.* +import com.samco.trackandgraph.graphstatview.ui.* +import com.samco.trackandgraph.ui.compose.theming.TnGComposeTheme + + +class GraphStatViewHolder( + private val composeView: ComposeView, +) : GroupChildViewHolder(composeView) { + + private var isElevated by mutableStateOf(false) + + private lateinit var graphStatViewData: IGraphStatViewData + private lateinit var clickListener: GraphStatClickListener + + fun bind( + graphStat: IGraphStatViewData, + clickListener: GraphStatClickListener + ) { + this.graphStatViewData = graphStat + this.clickListener = clickListener + + composeView.setContent { GraphStatContent(graphStat = graphStat) } + + //This fixes a bug because compose views don't calculate their height immediately, + // scrolling up through a recycler view causes jumpy behviour. See this issue: + // https://issuetracker.google.com/issues/240449681 + composeView.getChildAt(0)?.requestLayout() + } + + @Composable + private fun GraphStatContent(graphStat: IGraphStatViewData) = TnGComposeTheme { + key(graphStat) { + GraphStatCardView( + isElevated = isElevated, + graphStatViewData = graphStatViewData, + clickListener = clickListener + ) + } + } + + + override fun elevateCard() { + isElevated = true + } + + override fun dropCard() { + isElevated = false + } +} + +class GraphStatClickListener( + private val onDelete: (graphStat: IGraphStatViewData) -> Unit, + private val onEdit: (graphStat: IGraphStatViewData) -> Unit, + private val onClick: (graphStat: IGraphStatViewData) -> Unit, + private val onMoveGraphStat: (graphStat: IGraphStatViewData) -> Unit, + private val onDuplicateGraphStat: (graphStat: IGraphStatViewData) -> Unit +) { + fun onDelete(graphStat: IGraphStatViewData) = onDelete.invoke(graphStat) + fun onEdit(graphStat: IGraphStatViewData) = onEdit.invoke(graphStat) + fun onClick(graphStat: IGraphStatViewData) = onClick.invoke(graphStat) + fun onMoveGraphStat(graphStat: IGraphStatViewData) = onMoveGraphStat.invoke(graphStat) + fun onDuplicate(graphStat: IGraphStatViewData) = onDuplicateGraphStat.invoke(graphStat) +} diff --git a/app/src/main/java/com/samco/trackandgraph/group/GraphWithViewData.kt b/app/src/main/java/com/samco/trackandgraph/group/GraphWithViewData.kt new file mode 100644 index 000000000..1b3b6706f --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/group/GraphWithViewData.kt @@ -0,0 +1,8 @@ +package com.samco.trackandgraph.group + +import com.samco.trackandgraph.base.database.dto.GraphOrStat + +class GraphWithViewData( + val graph: GraphOrStat, + val viewData: CalculatedGraphViewData +) \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/group/GroupAdapter.kt b/app/src/main/java/com/samco/trackandgraph/group/GroupAdapter.kt new file mode 100644 index 000000000..335b8dfa2 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/group/GroupAdapter.kt @@ -0,0 +1,171 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.group + +import android.annotation.SuppressLint +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import androidx.compose.ui.platform.ComposeView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.StaggeredGridLayoutManager +import com.samco.trackandgraph.base.database.dto.GroupChildType + +class GroupAdapter( + private val trackerClickListener: TrackerClickListener, + private val graphStatClickListener: GraphStatClickListener, + private val groupClickListener: GroupClickListener, +) : RecyclerView.Adapter() { + private val groupChildren = mutableListOf() + + private enum class ViewType { + GRAPH, + TRACKER, + GROUP + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GroupChildViewHolder { + return when (viewType) { + ViewType.GRAPH.ordinal -> GraphStatViewHolder(ComposeView(parent.context)) + ViewType.TRACKER.ordinal -> TrackerViewHolder.from(parent) + else -> GroupViewHolder.from(parent).apply { setFullSpan(this) } + } + } + + private fun setFullSpan(vh: RecyclerView.ViewHolder) { + val layoutParams = StaggeredGridLayoutManager.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + layoutParams.isFullSpan = true + vh.itemView.layoutParams = layoutParams + } + + override fun onBindViewHolder(holder: GroupChildViewHolder, position: Int) { + val item = groupChildren[position] + when (getItemViewType(position)) { + ViewType.GRAPH.ordinal -> (holder as GraphStatViewHolder).bind( + (item as GroupChild.ChildGraph).graph.viewData, + graphStatClickListener + ) + + ViewType.TRACKER.ordinal -> (holder as TrackerViewHolder) + .bind((item as GroupChild.ChildTracker).displayTracker, trackerClickListener) + + ViewType.GROUP.ordinal -> (holder as GroupViewHolder) + .bind((item as GroupChild.ChildGroup).group, groupClickListener) + } + } + + override fun getItemViewType(position: Int): Int { + val child = groupChildren.getOrNull(position) ?: return -1 + return when (child) { + is GroupChild.ChildGraph -> ViewType.GRAPH.ordinal + is GroupChild.ChildTracker -> ViewType.TRACKER.ordinal + is GroupChild.ChildGroup -> ViewType.GROUP.ordinal + } + } + + override fun getItemCount(): Int = groupChildren.size + + @SuppressLint("NotifyDataSetChanged") + fun submitList(newChildren: List, forceNotifyDataSetChanged: Boolean) { + if (forceNotifyDataSetChanged) { + groupChildren.clear() + groupChildren.addAll(newChildren) + notifyDataSetChanged() + } else { + val diffCallback = ListDiffCallback(groupChildren, newChildren) + val diffResult = DiffUtil.calculateDiff(diffCallback) + groupChildren.clear() + groupChildren.addAll(newChildren) + diffResult.dispatchUpdatesTo(this) + } + } + + fun getItems(): List = groupChildren + + fun moveItem(start: Int, end: Int) { + if (start < 0 || start > groupChildren.size || end < 0 || end > groupChildren.size) return + val child = groupChildren.removeAt(start) + groupChildren.add(end, child) + notifyItemMoved(start, end) + } + + fun getSpanModeForItem(position: Int): SpanMode? { + if (position < 0 || position > groupChildren.size) return null + return when (groupChildren[position].type) { + GroupChildType.TRACKER -> SpanMode.NumSpans(1) + GroupChildType.GROUP -> SpanMode.NumSpans(2) + GroupChildType.GRAPH -> SpanMode.FullWidth + } + } +} + +sealed class SpanMode { + object FullWidth : SpanMode() + data class NumSpans(val spans: Int) : SpanMode() +} + +private class ListDiffCallback( + private val oldList: List, + private val newList: List +) : DiffUtil.Callback() { + override fun getOldListSize(): Int = oldList.size + + override fun getNewListSize(): Int = newList.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val old = oldList[oldItemPosition] + val new = newList[newItemPosition] + return old.type == new.type && old.id == new.id + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val old = oldList[oldItemPosition] + val new = newList[newItemPosition] + return when { + old is GroupChild.ChildGroup && new is GroupChild.ChildGroup -> { + val oldObj = old.group.copy(displayIndex = 0) + val newObj = new.group.copy(displayIndex = 0) + oldObj == newObj + } + + old is GroupChild.ChildTracker && new is GroupChild.ChildTracker -> { + val oldObj = old.displayTracker.copy(displayIndex = 0) + val newObj = new.displayTracker.copy(displayIndex = 0) + oldObj == newObj + } + + old is GroupChild.ChildGraph && new is GroupChild.ChildGraph -> { + return old.graph.time == new.graph.time + } + + else -> false + } + } +} + +abstract class GroupChildViewHolder(view: View) : RecyclerView.ViewHolder(view) { + abstract fun elevateCard() + abstract fun dropCard() + + /** + * @brief Called each seconds, usually to refresh timers + */ + open fun update() {} +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/group/GroupChild.kt b/app/src/main/java/com/samco/trackandgraph/group/GroupChild.kt new file mode 100644 index 000000000..e667924c7 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/group/GroupChild.kt @@ -0,0 +1,42 @@ +package com.samco.trackandgraph.group + +import com.samco.trackandgraph.base.database.dto.DisplayTracker +import com.samco.trackandgraph.base.database.dto.Group +import com.samco.trackandgraph.base.database.dto.GroupChildType + +sealed class GroupChild { + fun toDto() = com.samco.trackandgraph.base.database.dto.GroupChild( + type = when (this) { + is ChildGroup -> GroupChildType.GROUP + is ChildTracker -> GroupChildType.TRACKER + is ChildGraph -> GroupChildType.GRAPH + }, + id = id, + displayIndex = displayIndex + ) + + abstract val id: Long + abstract val displayIndex: Int + abstract val type: GroupChildType + + class ChildGroup( + override val id: Long, + override val displayIndex: Int, + val group: Group, + override val type: GroupChildType = GroupChildType.GROUP + ) : GroupChild() + + class ChildTracker( + override val id: Long, + override val displayIndex: Int, + val displayTracker: DisplayTracker, + override val type: GroupChildType = GroupChildType.TRACKER + ) : GroupChild() + + class ChildGraph( + override val id: Long, + override val displayIndex: Int, + val graph: CalculatedGraphViewData, + override val type: GroupChildType = GroupChildType.GRAPH + ) : GroupChild() +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/group/GroupFragment.kt b/app/src/main/java/com/samco/trackandgraph/group/GroupFragment.kt new file mode 100644 index 000000000..e6c666310 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/group/GroupFragment.kt @@ -0,0 +1,485 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.group + +import android.os.Bundle +import android.view.* +import androidx.appcompat.app.AlertDialog +import androidx.compose.runtime.CompositionLocalProvider +import androidx.core.content.ContextCompat +import androidx.core.view.MenuProvider +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavController +import androidx.navigation.NavDirections +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.* +import com.samco.trackandgraph.MainActivity +import com.samco.trackandgraph.NavButtonStyle +import com.samco.trackandgraph.R +import com.samco.trackandgraph.adddatapoint.AddDataPointsDialog +import com.samco.trackandgraph.adddatapoint.AddDataPointsViewModelImpl +import com.samco.trackandgraph.addgroup.AddGroupDialog +import com.samco.trackandgraph.addgroup.AddGroupDialogViewModelImpl +import com.samco.trackandgraph.base.database.dto.* +import com.samco.trackandgraph.databinding.FragmentGroupBinding +import com.samco.trackandgraph.addtracker.* +import com.samco.trackandgraph.graphstatview.factories.viewdto.IGraphStatViewData +import com.samco.trackandgraph.permissions.PermissionRequesterUseCase +import com.samco.trackandgraph.permissions.PermissionRequesterUseCaseImpl +import com.samco.trackandgraph.settings.TngSettings +import com.samco.trackandgraph.ui.* +import com.samco.trackandgraph.ui.compose.compositionlocals.LocalSettings +import com.samco.trackandgraph.util.bindingForViewLifecycle +import com.samco.trackandgraph.util.performTrackVibrate +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * The group fragment is used on the home page and in any nested group to display the contents of + * that group. It must display a list of groups, graphs and features contained within its group. + * The default value for args.groupId is 0L representing the root group or home page. The + * args.groupName may be null or empty. + */ +@AndroidEntryPoint +class GroupFragment : Fragment(), + YesCancelDialogFragment.YesCancelDialogListener, + PermissionRequesterUseCase by PermissionRequesterUseCaseImpl() { + private var navController: NavController? = null + private val args: GroupFragmentArgs by navArgs() + + private var binding: FragmentGroupBinding by bindingForViewLifecycle() + + private lateinit var adapter: GroupAdapter + private val viewModel by viewModels() + + private val addDataPointsDialogViewModel by viewModels() + private val addGroupDialogViewModel by viewModels() + + private var forceNextNotifyDataSetChanged: Boolean = false + + @Inject + lateinit var tngSettings: TngSettings + + init { + initNotificationsPermissionRequester(this) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = FragmentGroupBinding.inflate(inflater, container, false) + + binding.composeView.setContent { + CompositionLocalProvider(LocalSettings provides tngSettings) { + AddDataPointsDialog( + addDataPointsDialogViewModel, + onDismissRequest = { addDataPointsDialogViewModel.reset() } + ) + + AddGroupDialog( + viewModel = addGroupDialogViewModel, + onDismissRequest = { addGroupDialogViewModel.hide() } + ) + } + } + + this.navController = container?.findNavController() + binding.lifecycleOwner = viewLifecycleOwner + + viewModel.setGroup(args.groupId) + + binding.emptyGroupText.visibility = View.INVISIBLE + + initializeGridLayout() + adapter = GroupAdapter( + createTrackerClickListener(), + createGraphStatClickListener(), + createGroupClickListener() + ) + binding.itemList.adapter = adapter + //binding.itemList.itemAnimator = null + addItemTouchHelper() + scrollToTopOnItemAdded() + + binding.queueAddAllButton.hide() + binding.queueAddAllButton.setOnClickListener { onQueueAddAllClicked() } + registerForContextMenu(binding.itemList) + + listenToViewModel() + launchUpdateChildrenLoop() + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + requireActivity().addMenuProvider( + GroupMenuProvider(), + viewLifecycleOwner, + Lifecycle.State.RESUMED + ) + } + + private inner class GroupMenuProvider : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.group_menu, menu) + } + + override fun onMenuItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.add_tracker -> onAddTrackerClicked() + R.id.add_graph_stat -> onAddGraphStatClicked() + R.id.add_group -> onAddGroupClicked() + R.id.export_button -> onExportClicked() + R.id.import_button -> onImportClicked() + //R.id.add_function -> onAddFunctionClicked() + else -> return false + } + return true + } + } + + private fun onAddFunctionClicked() { + navigate(GroupFragmentDirections.actionAddFunction(args.groupId)) + } + + /** + * Calls an update function on all children of the recycler view once a second. This is + * because graphs/statistics and trackers can have timers in the view holder that need to + * be updated every second and it could be too costly to emit an entire new set of data to + * the adapter every second for diffing. + */ + private fun launchUpdateChildrenLoop() { + viewLifecycleOwner.lifecycleScope.launch { + while (true) { + delay(1000) + for (i in 0..(binding.itemList.adapter?.itemCount ?: 0)) { + (binding.itemList.findViewHolderForAdapterPosition(i) as GroupChildViewHolder?)?.update() + } + } + } + } + + private fun addItemTouchHelper() { + ItemTouchHelper(DragTouchHelperCallback( + { start: Int, end: Int -> adapter.moveItem(start, end) }, + { viewModel.adjustDisplayIndexes(adapter.getItems()) } + )).attachToRecyclerView(binding.itemList) + } + + private fun scrollToTopOnItemAdded() { + adapter.registerAdapterDataObserver( + object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + super.onItemRangeInserted(positionStart, itemCount) + //Scroll to the top when we've added something new to our group, + // but not when the adapter is being re-populated, e.g. when returning + // to this fragment from a nested group + if (itemCount == 1) binding.itemList.smoothScrollToPosition(0) + } + } + ) + } + + override fun onResume() { + super.onResume() + val activity = (requireActivity() as MainActivity) + args.groupName?.let { activity.setActionBarConfig(NavButtonStyle.UP, it) } + ?: run { activity.setActionBarConfig(NavButtonStyle.MENU) } + } + + private fun createGroupClickListener() = GroupClickListener( + this::onGroupSelected, + this::onEditGroupClicked, + this::onDeleteGroupClicked, + this::onMoveGroupClicked + ) + + private fun onMoveGroupClicked(group: Group) { + val dialog = MoveToDialogFragment() + val args = Bundle() + args.putString(MOVE_DIALOG_TYPE_KEY, MOVE_DIALOG_TYPE_GROUP) + args.putLong(MOVE_DIALOG_GROUP_KEY, group.id) + dialog.arguments = args + childFragmentManager.let { dialog.show(it, "move_dialog") } + } + + private fun onDeleteGroupClicked(group: Group) { + val dialog = YesCancelDialogFragment() + val args = Bundle() + args.putString("title", getString(R.string.ru_sure_del_group)) + args.putString("id", group.id.toString()) + dialog.arguments = args + childFragmentManager.let { dialog.show(it, "ru_sure_del_group_fragment") } + } + + private fun onEditGroupClicked(group: Group) { + addGroupDialogViewModel.show( + parentGroupId = group.parentGroupId, + groupId = group.id + ) + } + + private fun onGroupSelected(group: Group) { + navigate(GroupFragmentDirections.actionSelectGroup(group.id, group.name)) + } + + private fun createGraphStatClickListener() = GraphStatClickListener( + this::onDeleteGraphStatClicked, + this::onEditGraphStat, + this::onGraphStatClicked, + this::onMoveGraphStatClicked, + viewModel::duplicateGraphOrStat + ) + + private fun onDeleteGraphStatClicked(graphOrStat: IGraphStatViewData) { + val dialog = YesCancelDialogFragment() + val args = Bundle() + args.putString("title", getString(R.string.ru_sure_del_graph)) + args.putString("id", graphOrStat.graphOrStat.id.toString()) + dialog.arguments = args + childFragmentManager.let { dialog.show(it, "ru_sure_del_group_fragment") } + } + + private fun onMoveGraphStatClicked(graphOrStat: IGraphStatViewData) { + val dialog = MoveToDialogFragment() + val args = Bundle() + args.putString(MOVE_DIALOG_TYPE_KEY, MOVE_DIALOG_TYPE_GRAPH) + args.putLong(MOVE_DIALOG_GROUP_KEY, graphOrStat.graphOrStat.id) + dialog.arguments = args + childFragmentManager.let { dialog.show(it, "move_dialog") } + } + + private fun onGraphStatClicked(graphOrStat: IGraphStatViewData) { + navigate(GroupFragmentDirections.actionViewGraphStat(graphOrStat.graphOrStat.id)) + } + + private fun onEditGraphStat(graphOrStat: IGraphStatViewData) { + navigate( + GroupFragmentDirections.actionGraphStatInput( + graphStatId = graphOrStat.graphOrStat.id, + groupId = args.groupId + ) + ) + } + + private fun createTrackerClickListener() = TrackerClickListener( + this::onTrackerEditClicked, + this::onTrackerDeleteClicked, + this::onTrackerMoveClicked, + this::onTrackerDescriptionClicked, + this::onTrackerAddClicked, + this::onTrackerHistoryClicked, + this::onTrackerPlayTimerClicked, + this::onStopTimerClicked + ) + + private fun onTrackerPlayTimerClicked(tracker: DisplayTracker) { + viewModel.playTimer(tracker) + requestNotificationPermission(requireContext()) + } + + private fun onStopTimerClicked(tracker: DisplayTracker) { + //Due to a bug with the GridLayoutManager when you stop a timer and the timer text disappears + // the views heights are not properly re-calculated and we need to call notifyDataSetChanged + // to get the view heights right again + forceNextNotifyDataSetChanged = true + viewModel.stopTimer(tracker) + } + + private fun onTrackerHistoryClicked(tracker: DisplayTracker) { + navigate(GroupFragmentDirections.actionFeatureHistory(tracker.featureId, tracker.name)) + } + + private fun navigate(direction: NavDirections) { + //This check fixes a bug where calling navigate twice quickly would cause the app to crash + // https://stackoverflow.com/a/53737537 + if (navController?.currentDestination?.id?.equals(R.id.groupFragment) != true) return + navController?.navigate(direction) + } + + private fun onTrackerAddClicked(tracker: DisplayTracker, useDefault: Boolean = true) { + if (tracker.hasDefaultValue && useDefault) { + requireContext().performTrackVibrate() + viewModel.addDefaultTrackerValue(tracker) + } else addDataPointsDialogViewModel.showAddDataPointDialog(trackerId = tracker.id) + } + + private fun onTrackerDescriptionClicked(tracker: DisplayTracker) { + showFeatureDescriptionDialog(requireContext(), tracker.name, tracker.description) + } + + private fun onTrackerMoveClicked(tracker: DisplayTracker) { + val dialog = MoveToDialogFragment() + val args = Bundle() + args.putString(MOVE_DIALOG_TYPE_KEY, MOVE_DIALOG_TYPE_TRACK) + args.putLong(MOVE_DIALOG_GROUP_KEY, tracker.id) + dialog.arguments = args + childFragmentManager.let { dialog.show(it, "move_dialog") } + } + + private fun onTrackerEditClicked(tracker: DisplayTracker) { + navigate(GroupFragmentDirections.actionAddTracker(args.groupId, tracker.id)) + } + + private fun onQueueAddAllClicked() { + viewModel.trackers.let { trackers -> + addDataPointsDialogViewModel.showAddDataPointsDialog(trackerIds = trackers.map { it.id }) + } + } + + private fun initializeGridLayout() { + val dm = resources.displayMetrics + val screenWidth = dm.widthPixels / dm.density + val itemSize = (screenWidth / 2f).coerceAtMost(180f) + val spanCount = (screenWidth / itemSize).coerceAtLeast(2f).toInt() + val gridLayoutManager = GridLayoutManager(context, spanCount) + gridLayoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position: Int): Int { + return when (val spanMode = adapter.getSpanModeForItem(position)) { + SpanMode.FullWidth -> spanCount + is SpanMode.NumSpans -> spanMode.spans + null -> 1 + } + } + } + binding.itemList.layoutManager = gridLayoutManager + } + + private fun listenToViewModel() { + viewModel.loading.observe(viewLifecycleOwner) { + binding.loadingOverlay.visibility = if (it) View.VISIBLE else View.GONE + } + viewModel.hasTrackers.observe(viewLifecycleOwner) {} + viewModel.showEmptyGroupText.observe(viewLifecycleOwner) { + binding.emptyGroupText.visibility = if (it) View.VISIBLE else View.INVISIBLE + } + viewModel.allChildren.observe(viewLifecycleOwner) { + adapter.submitList(it, forceNextNotifyDataSetChanged) + forceNextNotifyDataSetChanged = false + updateShowQueueTrackButton() + } + viewModel.showDurationInputDialog.observe(viewLifecycleOwner) { + if (it == null) return@observe + + addDataPointsDialogViewModel.showAddDataPointDialog( + trackerId = it.trackerId, + customInitialValue = it.duration.seconds.toDouble() + ) + viewModel.onConsumedShowDurationInputDialog() + } + } + + private val queueAddAllButtonShowHideListener by lazy { + object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + if (dy > 0) binding.queueAddAllButton.hide() + else binding.queueAddAllButton.show() + } + } + } + + private fun updateShowQueueTrackButton() { + if (viewModel.trackers.isNotEmpty()) { + binding.queueAddAllButton.show() + binding.itemList.removeOnScrollListener(queueAddAllButtonShowHideListener) + binding.itemList.addOnScrollListener(queueAddAllButtonShowHideListener) + } else { + binding.itemList.removeOnScrollListener(queueAddAllButtonShowHideListener) + binding.queueAddAllButton.hide() + } + } + + override fun onStart() { + super.onStart() + (requireActivity() as MainActivity).toolbar.overflowIcon = + ContextCompat.getDrawable(requireContext(), R.drawable.add_icon) + } + + override fun onStop() { + super.onStop() + if (navController?.currentDestination?.id != R.id.groupFragment) { + (requireActivity() as MainActivity).toolbar.overflowIcon = + ContextCompat.getDrawable(requireContext(), R.drawable.list_menu_icon) + } + } + + private fun onExportClicked() { + val dialog = ExportFeaturesDialog() + val argBundle = Bundle() + argBundle.putLong(GROUP_ID_KEY, args.groupId) + argBundle.putString(GROUP_NAME_KEY, args.groupName) + dialog.arguments = argBundle + childFragmentManager.let { dialog.show(it, "export_features_dialog") } + } + + private fun onImportClicked() { + val dialog = ImportFeaturesDialog() + val argBundle = Bundle() + argBundle.putLong(GROUP_ID_KEY, args.groupId) + argBundle.putString(GROUP_NAME_KEY, args.groupName) + dialog.arguments = argBundle + childFragmentManager.let { dialog.show(it, "import_features_dialog") } + } + + private fun onAddTrackerClicked() { + navigate(GroupFragmentDirections.actionAddTracker(args.groupId)) + } + + private fun onAddGraphStatClicked() { + if (viewModel.hasTrackers.value != true) { + AlertDialog.Builder(requireContext()) + .setMessage(R.string.no_trackers_graph_stats_hint) + .setPositiveButton(R.string.ok) { dialog, _ -> dialog.dismiss() } + .show() + } else { + navigate(GroupFragmentDirections.actionGraphStatInput(groupId = args.groupId)) + } + } + + private fun onAddGroupClicked() { + addGroupDialogViewModel.show( + parentGroupId = args.groupId, + groupId = null + ) + } + + private fun onTrackerDeleteClicked(tracker: DisplayTracker) { + val dialog = YesCancelDialogFragment() + val args = Bundle() + args.putString("title", getString(R.string.ru_sure_del_feature)) + args.putString("id", tracker.featureId.toString()) + dialog.arguments = args + childFragmentManager.let { dialog.show(it, "ru_sure_del_feature_fragment") } + } + + override fun onDialogYes(dialog: YesCancelDialogFragment, id: String?) { + when (dialog.title) { + getString(R.string.ru_sure_del_feature) -> id?.let { viewModel.onDeleteFeature(it.toLong()) } + getString(R.string.ru_sure_del_group) -> id?.let { viewModel.onDeleteGroup(it.toLong()) } + getString(R.string.ru_sure_del_graph) -> id?.let { viewModel.onDeleteGraphStat(it.toLong()) } + } + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/group/GroupViewHolder.kt b/app/src/main/java/com/samco/trackandgraph/group/GroupViewHolder.kt new file mode 100644 index 000000000..97be04083 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/group/GroupViewHolder.kt @@ -0,0 +1,104 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.group + +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.PopupMenu +import androidx.core.content.ContextCompat +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.Group +import com.samco.trackandgraph.databinding.ListItemGroupBinding +import com.samco.trackandgraph.ui.dataVisColorList + +class GroupViewHolder private constructor(private val binding: ListItemGroupBinding) : + GroupChildViewHolder(binding.root), PopupMenu.OnMenuItemClickListener { + private var clickListener: GroupClickListener? = null + private var dropElevation = 0f + private var groupItem: Group? = null + + fun bind( + groupItem: Group, + clickListener: GroupClickListener + ) { + this.groupItem = groupItem + this.clickListener = clickListener + dropElevation = binding.cardView.cardElevation + binding.graphStatGroupNameText.text = groupItem.name + binding.cardView.setOnClickListener { clickListener.onClick(groupItem) } + binding.menuButton.setOnClickListener { createContextMenu(binding.menuButton) } + initCorner() + } + + private fun initCorner() { + val colorId = dataVisColorList[groupItem?.colorIndex ?: 8] + binding.cornerTabImage.setColorFilter(ContextCompat.getColor(binding.root.context, colorId)) + } + + override fun elevateCard() { + binding.cardView.postDelayed({ + binding.cardView.cardElevation = binding.cardView.cardElevation * 3f + }, 10) + } + + override fun dropCard() { + binding.cardView.cardElevation = dropElevation + } + + private fun createContextMenu(view: View) { + val popup = PopupMenu(view.context, view) + popup.menuInflater.inflate(R.menu.edit_group_context_menu, popup.menu) + popup.setOnMenuItemClickListener(this) + popup.show() + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + groupItem?.let { + when (item?.itemId) { + R.id.edit -> clickListener?.onEdit(it) + R.id.delete -> clickListener?.onDelete(it) + R.id.moveTo -> clickListener?.onMove(it) + else -> { + } + } + } + return false + } + + companion object { + fun from(parent: ViewGroup): GroupViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = ListItemGroupBinding.inflate(layoutInflater, parent, false) + return GroupViewHolder(binding) + } + } +} + +class GroupClickListener( + val clickListener: (group: Group) -> Unit, + val onEditListener: (group: Group) -> Unit, + val onDeleteListener: (group: Group) -> Unit, + val onMoveListener: (group: Group) -> Unit +) { + fun onClick(group: Group) = clickListener(group) + fun onEdit(group: Group) = onEditListener(group) + fun onDelete(group: Group) = onDeleteListener(group) + fun onMove(group: Group) = onMoveListener(group) +} diff --git a/app/src/main/java/com/samco/trackandgraph/group/GroupViewModel.kt b/app/src/main/java/com/samco/trackandgraph/group/GroupViewModel.kt new file mode 100644 index 000000000..4823f83dd --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/group/GroupViewModel.kt @@ -0,0 +1,479 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +@file:OptIn(FlowPreview::class) + +package com.samco.trackandgraph.group + +import androidx.lifecycle.* +import com.samco.trackandgraph.base.database.dto.* +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.DataUpdateType +import com.samco.trackandgraph.base.model.di.DefaultDispatcher +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.base.model.di.MainDispatcher +import com.samco.trackandgraph.graphstatproviders.GraphStatInteractorProvider +import com.samco.trackandgraph.graphstatview.factories.viewdto.IGraphStatViewData +import com.samco.trackandgraph.util.Stopwatch +import com.samco.trackandgraph.util.debounceBuffer +import com.samco.trackandgraph.util.flatMapLatestScan +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import org.threeten.bp.Duration +import org.threeten.bp.OffsetDateTime +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class GroupViewModel @Inject constructor( + private val dataInteractor: DataInteractor, + private val gsiProvider: GraphStatInteractorProvider, + @IODispatcher private val io: CoroutineDispatcher, + @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, + @MainDispatcher private val ui: CoroutineDispatcher +) : ViewModel() { + + data class DurationInputDialogData( + val trackerId: Long, + val duration: Duration + ) + + private val _showDurationInputDialog = MutableLiveData() + val showDurationInputDialog: LiveData = _showDurationInputDialog + + private val hasTrackersFlow = dataInteractor.hasAtLeastOneTracker() + + /** + * Show a loading screen until the database has been loaded in case we're in the middle of a + * heavy migration. + */ + private val databaseLoading = hasTrackersFlow + .map { false } + .flowOn(io) + .onStart { emit(true) } + + val loading = databaseLoading.asLiveData(viewModelScope.coroutineContext) + + val hasTrackers: LiveData = hasTrackersFlow + .asLiveData(viewModelScope.coroutineContext) + + private val groupId = MutableSharedFlow(1, 1) + + private sealed class UpdateType { + object Trackers : UpdateType() + object Groups : UpdateType() + object AllGraphs : UpdateType() + object GraphDeleted : UpdateType() + data class GraphsForFeature(val featureId: Long) : UpdateType() + data class Graph(val graphId: Long) : UpdateType() + object All : UpdateType() + object DisplayIndices : UpdateType() + object Preen : UpdateType() + } + + /** + * Whenever the database is updated get the type of update to determine what to update in the UI + */ + private fun asUpdateType(dataUpdateType: DataUpdateType) = when (dataUpdateType) { + is DataUpdateType.DataPoint -> listOf( + UpdateType.Trackers, + UpdateType.GraphsForFeature(dataUpdateType.featureId) + ) + + DataUpdateType.TrackerCreated -> listOf( + UpdateType.Trackers, + UpdateType.DisplayIndices + ) + + DataUpdateType.TrackerDeleted -> listOf( + UpdateType.Trackers, + UpdateType.DisplayIndices, + UpdateType.Preen, + UpdateType.AllGraphs + ) + + DataUpdateType.GroupCreated, DataUpdateType.GroupDeleted -> listOf( + UpdateType.Groups, + UpdateType.DisplayIndices + ) + + DataUpdateType.GroupDeleted -> listOf( + UpdateType.Groups, + UpdateType.Preen, + UpdateType.DisplayIndices + ) + + DataUpdateType.DisplayIndex -> listOf( + UpdateType.DisplayIndices + ) + + is DataUpdateType.GraphOrStatCreated -> listOf( + UpdateType.Graph(dataUpdateType.graphStatId), + UpdateType.DisplayIndices + ) + + DataUpdateType.GraphOrStatDeleted -> listOf( + UpdateType.DisplayIndices, + UpdateType.GraphDeleted + ) + + is DataUpdateType.GraphOrStatUpdated -> listOf( + UpdateType.Graph(dataUpdateType.graphStatId) + ) + + DataUpdateType.GroupUpdated -> listOf( + UpdateType.Groups + ) + + DataUpdateType.TrackerUpdated -> listOf( + UpdateType.Trackers + ) + + DataUpdateType.Function, DataUpdateType.GlobalNote, DataUpdateType.Reminder -> null + } + + private val onUpdateChildrenForGroup = + combine( + groupId.distinctUntilChanged(), + dataInteractor.getDataUpdateEvents() + .map { asUpdateType(it) } + .filterNotNull() + .flatMapConcat { it.asFlow() } + .onStart { emit(UpdateType.All) } + ) { a, b -> Pair(a, b) } + .shareIn(viewModelScope, SharingStarted.Lazily, 1) + + init { + viewModelScope.launch { + onUpdateChildrenForGroup + .filter { it.second == UpdateType.Preen } + .debounce(20) + .collect { pair -> preenGraphs(pair.first) } + } + } + + private suspend fun preenGraphs(groupId: Long) = withContext(io) { + getGraphObjects(groupId).forEach { + gsiProvider.getDataSourceAdapter(it.type).preen(it) + } + } + + private val graphChildren = onUpdateChildrenForGroup + .filter { + it.second in arrayOf( + UpdateType.All, + UpdateType.AllGraphs, + UpdateType.DisplayIndices, + UpdateType.GraphDeleted + ) + || it.second is UpdateType.GraphsForFeature + || it.second is UpdateType.Graph + } + .debounceBuffer(10) + //Get any buffered events and only emit the most significant one + .mapNotNull { bufferedEvents -> + sequence { + val types = bufferedEvents.associateBy { it.second } + types[UpdateType.All]?.let { yield(it) } + types[UpdateType.AllGraphs]?.let { yield(it) } + + //Special case if you delete a graph you typically get both display indices and + //graph deleted events. Graph deleted is more significant so we only emit that + if (types.size == 2 + && types.containsKey(UpdateType.GraphDeleted) + && types.containsKey(UpdateType.DisplayIndices) + ) { + yield(types[UpdateType.GraphDeleted]) + } + + //If we missed more than one specific event just update all + //graphs. This should happen very rarely + if (types.size > 1) yield(Pair(bufferedEvents[0].first, UpdateType.AllGraphs)) + else if (types.size == 1) yield(bufferedEvents[0]) + }.firstOrNull() + } + //Get the graph objects for the group + .scan, Pair>>( + Pair( + UpdateType.All, + emptyList() + ) + ) { pair, event -> + val lastGraphList = pair.second + val eventType = event.second + val groupId = event.first + + if (lastGraphList.isEmpty() || eventType !is UpdateType.GraphsForFeature) { + Pair(eventType, getGraphObjects(groupId)) + } + //Don't need to get the graphs from the database again if the update type is + //GraphsForFeature as this simply means a feature was updated and we need to + //recalculate the inner graph data + else Pair(eventType, lastGraphList) + } + //Get the graph view data for the graphs + .flatMapLatestScan(emptyList()) { viewData, graphUpdate -> + val (type, graphStats) = graphUpdate + + //Get the graph data for any graphs that need updating + return@flatMapLatestScan when (type) { + UpdateType.All, UpdateType.AllGraphs -> getGraphViewData(graphStats) + UpdateType.DisplayIndices, UpdateType.GraphDeleted -> { + mapNewGraphsToOldViewData(viewData, graphStats) + } + + is UpdateType.Graph -> withUpdatedGraph(viewData, graphStats, type.graphId) + is UpdateType.GraphsForFeature -> + withUpdatedIfAffected(viewData, graphStats, type.featureId) + //Shouldn't ever happen + else -> flowOf(viewData) + } + } + .map { graphsToGroupChildren(it) } + .flowOn(io) + + private fun withUpdatedIfAffected( + viewData: List, + graphStats: List, + featureId: Long + ): Flow> = flow { + + val affected = viewData + .map { it.graph } + .filter { gsiProvider.getDataFactory(it.type).affectedBy(it.id, featureId) } + .map { it.id } + + val dontUpdate = viewData.filter { it.graph.id !in affected } + + getGraphViewData(graphStats.filter { it.id in affected }) + .collect { emit(dontUpdate + it) } + } + + private fun withUpdatedGraph( + viewData: List, + newGraphStats: List, + updateGraphId: Long + ): Flow> { + val dontUpdate = viewData.filter { it.graph.id != updateGraphId } + return getGraphViewData(newGraphStats.filter { it.id == updateGraphId }) + .map { dontUpdate + it } + } + + private suspend fun getGraphObjects(groupId: Long): List = dataInteractor + .getGraphsAndStatsByGroupIdSync(groupId) + + private fun graphsToGroupChildren(graphs: List) = graphs + .map { GroupChild.ChildGraph(it.graph.id, it.graph.displayIndex, it.viewData) } + + private fun mapNewGraphsToOldViewData( + viewData: List, + newGraphStats: List + ): Flow> { + val oldGraphsById = viewData.associateBy { it.graph.id } + + val dontUpdate = newGraphStats + .map { Pair(it, oldGraphsById[it.id]?.viewData) } + .filter { it.second?.viewData?.state == IGraphStatViewData.State.READY } + .mapNotNull { pair -> pair.second?.let { GraphWithViewData(pair.first, it) } } + + val update = newGraphStats + .map { Pair(it, oldGraphsById[it.id]?.viewData) } + .filter { it.second?.viewData?.state != IGraphStatViewData.State.READY } + .map { it.first } + + return getGraphViewData(update).map { dontUpdate + it } + } + + private fun GraphOrStat.asLoading() = GraphWithViewData( + this, + CalculatedGraphViewData( + System.nanoTime(), + IGraphStatViewData.loading(this) + ) + ) + + private fun getGraphViewData(graphs: List): Flow> = + flow { + val stopwatch = Stopwatch().apply { start() } + + emit(graphs.map { it.asLoading() }) + + val batch = mutableListOf>() + for (graph in graphs) { + val viewData = viewModelScope.async(defaultDispatcher) { + val calculatedData = gsiProvider.getDataFactory(graph.type).getViewData(graph) + GraphWithViewData( + graph, + //Shouldn't really need to add one here but it just forces the times to be different + // There was a bug previously where the loading and ready states had the same time using + // Instant.now() which caused ready states to be missed and infinite loading to be shown + CalculatedGraphViewData(System.nanoTime() + 1, calculatedData) + ) + } + batch.add(viewData) + } + + emit(batch.awaitAll()) + + stopwatch.stop() + Timber.i("Took ${stopwatch.elapsedMillis}ms to generate view data for ${graphs.size} graph(s)") + } + + private val trackersChildren = onUpdateChildrenForGroup + .filter { + it.second in arrayOf( + UpdateType.Trackers, + UpdateType.All, + UpdateType.DisplayIndices, + ) + } + .debounce(10L) + .map { getTrackerChildren(it.first) } + .flowOn(io) + + private val groupChildren = onUpdateChildrenForGroup + .filter { + it.second in arrayOf( + UpdateType.Groups, + UpdateType.All, + UpdateType.DisplayIndices, + ) + } + .debounce(10L) + .map { getGroupChildren(it.first) } + .flowOn(io) + + private val allChildrenFlow: StateFlow> = + combine(graphChildren, trackersChildren, groupChildren) { a, b, c -> Triple(a, b, c) } + //This debounce should be longer than the children debounce + .debounce(50L) + .map { (graphs, trackers, groups) -> + val children = mutableListOf().apply { + addAll(graphs) + addAll(trackers) + addAll(groups) + } + sortChildren(children) + children + } + .flowOn(io) + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + val allChildren = allChildrenFlow.asLiveData(viewModelScope.coroutineContext) + + val showEmptyGroupText: LiveData = combine( + allChildrenFlow.map { it.isEmpty() }, + databaseLoading, + groupId + ) { childrenEmpty, loading, groupId -> + childrenEmpty && !loading && groupId == 0L + } + .onStart { emit(false) } + .distinctUntilChanged() + .asLiveData(viewModelScope.coroutineContext) + + val trackers + get() = allChildren.value + ?.filterIsInstance() + ?.map { it.displayTracker } + ?: emptyList() + + fun setGroup(groupId: Long) { + viewModelScope.launch { this@GroupViewModel.groupId.emit(groupId) } + } + + private fun sortChildren(children: MutableList) = children.sortWith { a, b -> + val aInd = a.displayIndex + val bInd = b.displayIndex + when { + aInd < bInd -> -1 + bInd < aInd -> 1 + else -> { + val aId = a.id + val bId = b.id + when { + aId > bId -> -1 + bId > aId -> 1 + else -> 0 + } + } + } + } + + private suspend fun getTrackerChildren(groupId: Long): List { + return dataInteractor.getDisplayTrackersForGroupSync(groupId).map { + GroupChild.ChildTracker(it.id, it.displayIndex, it) + } + } + + private suspend fun getGroupChildren(groupId: Long): List { + return dataInteractor.getGroupsForGroupSync(groupId).map { + GroupChild.ChildGroup(it.id, it.displayIndex, it) + } + } + + fun addDefaultTrackerValue(tracker: DisplayTracker) = viewModelScope.launch(io) { + val newDataPoint = DataPoint( + timestamp = OffsetDateTime.now(), + featureId = tracker.featureId, + value = tracker.defaultValue, + label = tracker.defaultLabel, + note = "" + ) + dataInteractor.insertDataPoint(newDataPoint) + } + + fun onDeleteFeature(id: Long) = viewModelScope.launch(io) { + dataInteractor.deleteFeature(id) + } + + fun adjustDisplayIndexes(items: List) = viewModelScope.launch(io) { + groupId.take(1).collect { + dataInteractor.updateGroupChildOrder(it, items.map(GroupChild::toDto)) + } + } + + fun onDeleteGraphStat(id: Long) = + viewModelScope.launch(io) { dataInteractor.deleteGraphOrStat(id) } + + fun onDeleteGroup(id: Long) = + viewModelScope.launch(io) { dataInteractor.deleteGroup(id) } + + fun duplicateGraphOrStat(graphOrStatViewData: IGraphStatViewData) { + viewModelScope.launch(io) { + val gs = graphOrStatViewData.graphOrStat + gsiProvider.getDataSourceAdapter(gs.type).duplicateGraphOrStat(gs) + } + } + + fun onConsumedShowDurationInputDialog() { + _showDurationInputDialog.value = null + } + + fun stopTimer(tracker: DisplayTracker) = viewModelScope.launch(io) { + dataInteractor.stopTimerForTracker(tracker.id)?.let { + withContext(ui) { + _showDurationInputDialog.value = DurationInputDialogData(tracker.id, it) + } + } + } + + fun playTimer(tracker: DisplayTracker) = viewModelScope.launch(io) { + dataInteractor.playTimerForTracker(tracker.id) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/group/TrackerViewHolder.kt b/app/src/main/java/com/samco/trackandgraph/group/TrackerViewHolder.kt new file mode 100644 index 000000000..06ba75191 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/group/TrackerViewHolder.kt @@ -0,0 +1,206 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.group + +import android.annotation.SuppressLint +import android.view.* +import android.widget.PopupMenu +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.DataType +import com.samco.trackandgraph.base.database.dto.DisplayTracker +import com.samco.trackandgraph.base.helpers.formatRelativeTimeSpan +import com.samco.trackandgraph.base.helpers.formatTimeDuration +import com.samco.trackandgraph.databinding.ListItemTrackerBinding +import org.threeten.bp.Duration +import org.threeten.bp.Instant + +class TrackerViewHolder private constructor( + private val binding: ListItemTrackerBinding, +) : GroupChildViewHolder(binding.root), PopupMenu.OnMenuItemClickListener { + private var clickListener: TrackerClickListener? = null + private var tracker: DisplayTracker? = null + private var dropElevation = 0f + + private val context = binding.root.context + + fun bind(tracker: DisplayTracker, clickListener: TrackerClickListener) { + this.tracker = tracker + this.clickListener = clickListener + this.dropElevation = binding.cardView.cardElevation + setLastDateText() + binding.trackerNameText.text = tracker.name + binding.menuButton.setOnClickListener { createContextMenu(binding.menuButton) } + initClickEvents(tracker, clickListener) + initTimerControls(tracker, clickListener) + } + + private fun initTimerControls(tracker: DisplayTracker, clickListener: TrackerClickListener) { + if (tracker.dataType != DataType.DURATION) { + binding.playTimerButton.visibility = View.GONE + binding.stopTimerButton.visibility = View.GONE + binding.timerText.visibility = View.GONE + binding.lastDateText.visibility = View.VISIBLE + } else if (tracker.timerStartInstant != null) { + binding.timerText.visibility = View.VISIBLE + binding.playTimerButton.visibility = View.GONE + binding.stopTimerButton.visibility = View.VISIBLE + binding.lastDateText.visibility = View.GONE + updateTimerText() + } else { + binding.timerText.visibility = View.GONE + binding.playTimerButton.visibility = View.VISIBLE + binding.stopTimerButton.visibility = View.GONE + binding.lastDateText.visibility = View.VISIBLE + binding.timerText.text = formatTimeDuration(0) + } + + binding.playTimerButton.setOnClickListener { + clickListener.onPlayTimer(tracker) + } + binding.stopTimerButton.setOnClickListener { + clickListener.onStopTimer(tracker) + } + } + + override fun update() { + super.update() + updateTimerText() + setLastDateText() + } + + private fun updateTimerText() { + tracker?.timerStartInstant?.let { + val duration = Duration.between(it, Instant.now()) + binding.timerText.text = formatTimeDuration(duration.seconds) + } + } + + @SuppressLint("ClickableViewAccessibility") + private fun initClickEvents(tracker: DisplayTracker, clickListener: TrackerClickListener) { + var lastX = 0 + var lastY = 0 + val isInCorner = { + val rect = binding.innerLayoutContainer + + //45dp in px + val cornerSize = 45 * context.resources.displayMetrics.density + + lastX > (rect.width - cornerSize) + && lastX < rect.width + && lastY > (rect.height - cornerSize) + && lastY < rect.height + } + binding.cardView.setOnLongClickListener { + if (isInCorner()) { + clickListener.onAdd(tracker, false) + return@setOnLongClickListener true + } + return@setOnLongClickListener false + } + binding.cardView.setOnClickListener { + if (isInCorner()) { + clickListener.onAdd(tracker) + } else { + clickListener.onHistory(tracker) + } + } + binding.innerLayoutContainer.setOnTouchListener { _, event -> + //ignore this listener but update lastX and lastY + if (event.action == MotionEvent.ACTION_DOWN || event.action == MotionEvent.ACTION_MOVE) { + lastX = event.x.toInt() + lastY = event.y.toInt() + } + return@setOnTouchListener false + } + if (tracker.hasDefaultValue) { + binding.addButton.visibility = View.INVISIBLE + binding.quickAddButton.visibility = View.VISIBLE + } else { + binding.addButton.visibility = View.VISIBLE + binding.quickAddButton.visibility = View.INVISIBLE + } + } + + private fun setLastDateText() { + val timestamp = tracker?.timestamp + binding.lastDateText.text = if (timestamp == null) { + context.getString(R.string.no_data) + } else { + formatRelativeTimeSpan(context, timestamp) + } + } + + override fun elevateCard() { + binding.cardView.postDelayed({ binding.cardView.cardElevation *= 3f }, 10) + } + + override fun dropCard() { + binding.cardView.cardElevation = dropElevation + } + + private fun createContextMenu(view: View) { + val popup = PopupMenu(view.context, view) + popup.menuInflater.inflate(R.menu.edit_tracker_context_menu, popup.menu) + popup.setOnMenuItemClickListener(this) + popup.show() + } + + override fun onMenuItemClick(item: MenuItem?): Boolean { + tracker?.let { + when (item?.itemId) { + R.id.edit -> clickListener?.onEdit(it) + R.id.delete -> clickListener?.onDelete(it) + R.id.moveTo -> clickListener?.onMoveTo(it) + R.id.description -> clickListener?.onDescription(it) + else -> { + } + } + } + return false + } + + companion object { + fun from(parent: ViewGroup): TrackerViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = ListItemTrackerBinding.inflate(layoutInflater, parent, false) + return TrackerViewHolder(binding) + } + } +} + +class TrackerClickListener( + private val onEditListener: (tracker: DisplayTracker) -> Unit, + private val onDeleteListener: (tracker: DisplayTracker) -> Unit, + private val onMoveToListener: (tracker: DisplayTracker) -> Unit, + private val onDescriptionListener: (tracker: DisplayTracker) -> Unit, + private val onAddListener: (tracker: DisplayTracker, useDefault: Boolean) -> Unit, + private val onHistoryListener: (tracker: DisplayTracker) -> Unit, + private val onPlayTimerListener: (tracker: DisplayTracker) -> Unit, + private val onStopTimerListener: (tracker: DisplayTracker) -> Unit, +) { + fun onEdit(tracker: DisplayTracker) = onEditListener(tracker) + fun onDelete(tracker: DisplayTracker) = onDeleteListener(tracker) + fun onMoveTo(tracker: DisplayTracker) = onMoveToListener(tracker) + fun onDescription(tracker: DisplayTracker) = onDescriptionListener(tracker) + fun onAdd(tracker: DisplayTracker, useDefault: Boolean = true) = + onAddListener(tracker, useDefault) + + fun onHistory(tracker: DisplayTracker) = onHistoryListener(tracker) + fun onPlayTimer(tracker: DisplayTracker) = onPlayTimerListener(tracker) + fun onStopTimer(tracker: DisplayTracker) = onStopTimerListener(tracker) +} diff --git a/app/src/main/java/com/samco/trackandgraph/navigation/PendingIntentProviderImpl.kt b/app/src/main/java/com/samco/trackandgraph/navigation/PendingIntentProviderImpl.kt new file mode 100644 index 000000000..98b4238cb --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/navigation/PendingIntentProviderImpl.kt @@ -0,0 +1,139 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.navigation + +import android.app.ActivityOptions +import android.app.ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import com.samco.trackandgraph.MainActivity +import com.samco.trackandgraph.base.navigation.PendingIntentProvider +import com.samco.trackandgraph.base.service.TrackWidgetProvider +import com.samco.trackandgraph.base.service.TrackWidgetProvider.Companion.DELETE_FEATURE_ID +import com.samco.trackandgraph.base.service.TrackWidgetProvider.Companion.UPDATE_FEATURE_ID +import com.samco.trackandgraph.base.service.TrackWidgetProvider.Companion.UPDATE_FEATURE_TIMER +import com.samco.trackandgraph.timers.AddDataPointFromTimerActivity +import com.samco.trackandgraph.widgets.TrackWidgetInputDataPointActivity +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class PendingIntentProviderImpl @Inject constructor( + @ApplicationContext private val context: Context +) : PendingIntentProvider { + override fun getMainActivityPendingIntent(): PendingIntent { + return Intent(context, MainActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + .let { + PendingIntent.getActivity( + context, + 0, + it, + PendingIntent.FLAG_IMMUTABLE, + getStartActivityOptionsBundle() + ) + } + } + + override fun getDurationInputActivityIntent(trackerId: Long, startInstant: String): Intent { + return Intent(context, AddDataPointFromTimerActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(AddDataPointFromTimerActivity.TRACKER_ID_KEY, trackerId) + .putExtra(AddDataPointFromTimerActivity.START_TIME_KEY, startInstant) + } + + private fun getStartActivityOptionsBundle() = + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ActivityOptions.makeBasic() + .setPendingIntentBackgroundActivityStartMode( + MODE_BACKGROUND_ACTIVITY_START_ALLOWED + ) + .toBundle() + } else null + + override fun getDurationInputActivityPendingIntent( + trackerId: Long, + startInstant: String + ): PendingIntent { + return getDurationInputActivityIntent(trackerId, startInstant).let { + PendingIntent.getActivity( + context, + //A key unique to this request to allow updating notification + startInstant.hashCode() + trackerId.toInt(), + it, + PendingIntent.FLAG_IMMUTABLE, + getStartActivityOptionsBundle() + ) + } + } + + override fun getTrackWidgetInputDataPointActivityPendingIntent(appWidgetId: Int): PendingIntent { + return Intent(context, TrackWidgetInputDataPointActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + }.let { + PendingIntent.getActivity( + context, + appWidgetId, + it, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + getStartActivityOptionsBundle() + ) + } + } + + override fun getTrackWidgetDisableForFeatureByIdIntent(featureId: Long): Intent { + return Intent( + AppWidgetManager.ACTION_APPWIDGET_UPDATE, + null, + context, + TrackWidgetProvider::class.java + ).apply { putExtra(DELETE_FEATURE_ID, featureId) } + } + + override fun getTrackWidgetUpdateForFeatureIdIntent(featureId: Long): Intent { + return Intent( + AppWidgetManager.ACTION_APPWIDGET_UPDATE, + null, + context, + TrackWidgetProvider::class.java + ).apply { putExtra(UPDATE_FEATURE_ID, featureId) } + } + + override fun getTrackWidgetStartStopTimerIntent( + appWidgetId: Int, + featureId: Long, + startTimer: Boolean + ): PendingIntent { + return Intent( + AppWidgetManager.ACTION_APPWIDGET_UPDATE, + null, context, TrackWidgetProvider::class.java + ).apply { + putExtra(UPDATE_FEATURE_ID, featureId) + putExtra(UPDATE_FEATURE_TIMER, startTimer) + }.let { + PendingIntent.getBroadcast( + context, + appWidgetId, + it, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/notes/GlobalNoteInputDialogView.kt b/app/src/main/java/com/samco/trackandgraph/notes/GlobalNoteInputDialogView.kt new file mode 100644 index 000000000..54c19270c --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/notes/GlobalNoteInputDialogView.kt @@ -0,0 +1,122 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +@file:OptIn(ExperimentalComposeUiApi::class) + +package com.samco.trackandgraph.notes + +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.samco.trackandgraph.R +import com.samco.trackandgraph.ui.compose.theming.DialogTheme +import com.samco.trackandgraph.ui.compose.ui.* +import kotlinx.coroutines.delay +import org.threeten.bp.OffsetDateTime + +@Composable +fun GlobalNoteInputDialogView(viewModel: GlobalNoteInputViewModel) { + DialogTheme { + Dialog( + onDismissRequest = { viewModel.onCancelConfirmed() }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false + ) + ) { + GlobalNoteDialogViewContent(viewModel) + if (viewModel.showConfirmCancelDialog.observeAsState(false).value) { + ConfirmCancelDialog( + onDismissRequest = viewModel::onCancelDismissed, + onConfirm = viewModel::onCancelConfirmed, + body = R.string.confirm_cancel_notes_will_be_lost + ) + } + } + } +} + +@Composable +private fun GlobalNoteDialogViewContent(viewModel: GlobalNoteInputViewModel) = Surface { + Column( + modifier = Modifier + .heightIn(max = 400.dp) + .fillMaxWidth(0.9f) + .padding(dimensionResource(id = R.dimen.card_padding)) + ) { + val selectedDateTime by viewModel.dateTime.observeAsState(OffsetDateTime.now()) + + val focusRequester = remember { FocusRequester() } + + InputSpacingLarge() + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.add_a_global_note), + style = MaterialTheme.typography.h5, + textAlign = TextAlign.Center + ) + + InputSpacingLarge() + + DateTimeButtonRow( + modifier = Modifier.fillMaxWidth(), + selectedDateTime = selectedDateTime, + onDateTimeSelected = { viewModel.updateTimeStamp(it) } + ) + + DialogInputSpacing() + + FullWidthTextField( + modifier = Modifier.heightIn(max = 200.dp), + textFieldValue = viewModel.note, + onValueChange = { viewModel.updateNoteText(it) }, + focusRequester = focusRequester, + label = stringResource(id = R.string.note_input_hint), + singleLine = false + ) + + DialogInputSpacing() + + AddCancelBottomButtons( + updateMode = viewModel.updateMode.observeAsState(false).value, + onCancelClicked = viewModel::onCancelClicked, + onAddClicked = viewModel::onAddClicked, + addButtonEnabled = viewModel.addButtonEnabled.observeAsState(false).value + ) + + LaunchedEffect(focusRequester) { + delay(10L) + focusRequester.requestFocus() + } + + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/notes/GlobalNoteInputViewModel.kt b/app/src/main/java/com/samco/trackandgraph/notes/GlobalNoteInputViewModel.kt new file mode 100644 index 000000000..de605b85f --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/notes/GlobalNoteInputViewModel.kt @@ -0,0 +1,148 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.notes + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.* +import com.samco.trackandgraph.base.database.dto.GlobalNote +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.base.model.di.MainDispatcher +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.threeten.bp.OffsetDateTime +import javax.inject.Inject + +interface GlobalNoteInputViewModel { + val show: LiveData + val note: TextFieldValue + val dateTime: LiveData + val updateMode: LiveData + val addButtonEnabled: LiveData + val showConfirmCancelDialog: LiveData + + fun openDialog(timestamp: OffsetDateTime?) + fun updateNoteText(text: TextFieldValue) + fun updateTimeStamp(timeStamp: OffsetDateTime) + fun onAddClicked() + fun onCancelClicked() + fun onCancelConfirmed() + fun onCancelDismissed() +} + +@HiltViewModel +class GlobalNoteInputViewModelImpl @Inject constructor( + private val dataInteractor: DataInteractor, + @IODispatcher private val io: CoroutineDispatcher, + @MainDispatcher private val ui: CoroutineDispatcher +) : ViewModel(), GlobalNoteInputViewModel { + + private val onCancelConfirmedEvents = MutableSharedFlow() + + private var foundNote: GlobalNote? = null + + override var note by mutableStateOf(TextFieldValue()) + override val dateTime = MutableLiveData() + + override val updateMode = MutableLiveData(false) + + override val addButtonEnabled = snapshotFlow { note } + .map { it.text.isNotBlank() } + .asLiveData(viewModelScope.coroutineContext) + + override val showConfirmCancelDialog = MutableLiveData(false) + + private val addCompleteEvents = MutableSharedFlow() + private val showEvents = MutableSharedFlow() + + override val show = merge( + showEvents.map { true }, + merge(onCancelConfirmedEvents, addCompleteEvents).map { false } + ).asLiveData(viewModelScope.coroutineContext) + + override fun openDialog(timestamp: OffsetDateTime?) { + viewModelScope.launch(io) { + val globalNote = timestamp?.let { + dataInteractor.getGlobalNoteByTimeSync(it) + } + withContext(ui) { + if (globalNote != null) initFromGlobalNote(globalNote) + else initForNewNote() + showConfirmCancelDialog.value = false + } + showEvents.emit(Unit) + } + } + + private fun initForNewNote() { + foundNote = null + this.note = TextFieldValue() + this.dateTime.value = OffsetDateTime.now() + this.updateMode.value = false + } + + private fun initFromGlobalNote(note: GlobalNote) { + foundNote = note + this.note = TextFieldValue(note.note, TextRange(note.note.length)) + this.dateTime.value = note.timestamp + this.updateMode.value = true + } + + override fun updateNoteText(text: TextFieldValue) { + note = text + } + + override fun updateTimeStamp(timeStamp: OffsetDateTime) { + dateTime.value = timeStamp + } + + override fun onAddClicked() { + viewModelScope.launch(io) { + foundNote?.let { dataInteractor.deleteGlobalNote(it) } + dataInteractor.insertGlobalNote( + GlobalNote( + note = note.text, + timestamp = dateTime.value ?: OffsetDateTime.now() + ) + ) + + withContext(ui) { addCompleteEvents.emit(Unit) } + } + } + + override fun onCancelClicked() { + if (note.text.isNotBlank()) showConfirmCancelDialog.value = true + else viewModelScope.launch { onCancelConfirmedEvents.emit(Unit) } + } + + override fun onCancelConfirmed() { + viewModelScope.launch { onCancelConfirmedEvents.emit(Unit) } + } + + override fun onCancelDismissed() { + showConfirmCancelDialog.value = false + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/notes/NotesFragment.kt b/app/src/main/java/com/samco/trackandgraph/notes/NotesFragment.kt new file mode 100644 index 000000000..5ae0ebd0c --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/notes/NotesFragment.kt @@ -0,0 +1,97 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.notes + +import android.os.Bundle +import android.view.* +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.ComposeView +import androidx.core.view.MenuProvider +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import com.samco.trackandgraph.MainActivity +import com.samco.trackandgraph.NavButtonStyle +import com.samco.trackandgraph.R +import com.samco.trackandgraph.adddatapoint.AddDataPointsViewModelImpl +import com.samco.trackandgraph.settings.TngSettings +import com.samco.trackandgraph.ui.compose.compositionlocals.LocalSettings +import com.samco.trackandgraph.ui.compose.theming.TnGComposeTheme +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class NotesFragment : Fragment() { + private val notesViewModel by viewModels() + private val addDataPointsDialogViewModel by viewModels() + private val globalNoteDialogViewModel: GlobalNoteInputViewModel by viewModels() + + @Inject + lateinit var tngSettings: TngSettings + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setContent { + CompositionLocalProvider(LocalSettings provides tngSettings) { + TnGComposeTheme { + NotesView( + notesViewModel = notesViewModel, + addDataPointsDialogViewModel = addDataPointsDialogViewModel, + globalNoteDialogViewModel = globalNoteDialogViewModel + ) + } + } + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + requireActivity().addMenuProvider( + NotesMenuProvider(), + viewLifecycleOwner, + Lifecycle.State.RESUMED + ) + } + + private inner class NotesMenuProvider : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.notes_menu, menu) + } + + override fun onMenuItemSelected(item: MenuItem): Boolean { + if (item.itemId == R.id.add_global_note) { + globalNoteDialogViewModel.openDialog(null) + return true + } + return false + } + } + + override fun onResume() { + super.onResume() + (requireActivity() as MainActivity).setActionBarConfig( + NavButtonStyle.MENU, + getString(R.string.notes) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/notes/NotesView.kt b/app/src/main/java/com/samco/trackandgraph/notes/NotesView.kt new file mode 100644 index 000000000..d02d5e3e7 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/notes/NotesView.kt @@ -0,0 +1,209 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.notes + +import android.view.LayoutInflater +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Card +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import com.samco.trackandgraph.R +import com.samco.trackandgraph.adddatapoint.AddDataPointsDialog +import com.samco.trackandgraph.adddatapoint.AddDataPointsViewModelImpl +import com.samco.trackandgraph.base.helpers.formatDayMonthYearHourMinuteWeekDayOneLine +import com.samco.trackandgraph.base.helpers.getWeekDayNames +import com.samco.trackandgraph.ui.compose.theming.tngColors +import com.samco.trackandgraph.ui.compose.ui.DateScrollLazyColumn +import com.samco.trackandgraph.ui.compose.ui.EmptyScreenText +import com.samco.trackandgraph.ui.compose.ui.HalfDialogInputSpacing +import com.samco.trackandgraph.ui.showNoteDialog + +@Composable +fun NotesView( + notesViewModel: NotesViewModel, + addDataPointsDialogViewModel: AddDataPointsViewModelImpl, + globalNoteDialogViewModel: GlobalNoteInputViewModel +) { + val dateScrollData = notesViewModel.dateScrollData.observeAsState().value + + val context = LocalContext.current + + if (dateScrollData == null || dateScrollData.items.isEmpty()) { + EmptyScreenText(textId = R.string.no_data_points_history_fragment_hint) + } else { + DateScrollLazyColumn( + modifier = Modifier.padding(dimensionResource(id = R.dimen.card_margin_small)), + data = dateScrollData + ) { note -> + Note( + noteInfo = note, + onNoteClick = { + showNoteDialog( + LayoutInflater.from(context), + context, + note.toDisplayNote(), + note.featurePath + ) + }, + onEditClick = { + if (note.trackerId != null) { + addDataPointsDialogViewModel.showAddDataPointDialog( + trackerId = note.trackerId, + dataPointTimestamp = note.date + ) + } else globalNoteDialogViewModel.openDialog(note.date) + }, + onDeleteClick = { notesViewModel.deleteNote(note) } + ) + Spacer(modifier = Modifier.height(dimensionResource(id = R.dimen.card_margin_small))) + } + } + + AddDataPointsDialog( + viewModel = addDataPointsDialogViewModel, + onDismissRequest = { addDataPointsDialogViewModel.reset() } + ) + + if (globalNoteDialogViewModel.show.observeAsState(false).value) { + GlobalNoteInputDialogView(viewModel = globalNoteDialogViewModel) + } +} + +@Composable +private fun Note( + noteInfo: NoteInfo, + onNoteClick: () -> Unit, + onEditClick: () -> Unit, + onDeleteClick: () -> Unit, +) = Card( + modifier = Modifier.clickable { onNoteClick() }, + elevation = dimensionResource(id = R.dimen.card_elevation), + shape = MaterialTheme.shapes.small +) { + + var boxEnd by remember { mutableStateOf(0) } + + Box(modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { boxEnd = it.size.width } + ) { + var menuExpanded by remember { mutableStateOf(false) } + + IconButton( + modifier = Modifier.align(Alignment.TopEnd), + onClick = { menuExpanded = true } + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.list_menu_icon), + contentDescription = stringResource(id = R.string.edit), + tint = MaterialTheme.tngColors.onSurface + ) + } + + DropdownMenu( + expanded = menuExpanded, + offset = DpOffset(boxEnd.dp, 0.dp), + onDismissRequest = { menuExpanded = false } + ) { + DropdownMenuItem(onClick = { + menuExpanded = false + onEditClick() + }) { + Text( + text = stringResource(id = R.string.edit), + style = MaterialTheme.typography.body1 + ) + } + + DropdownMenuItem(onClick = { + menuExpanded = false + onDeleteClick() + }) { + Text( + text = stringResource(id = R.string.delete), + style = MaterialTheme.typography.body1 + ) + } + } + + } + + val context = LocalContext.current + val weekDayNames = remember(Locale.current) { + getWeekDayNames(context) + } + + val dateText = remember(noteInfo.date) { + formatDayMonthYearHourMinuteWeekDayOneLine( + context, + weekDayNames, + noteInfo.date + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(dimensionResource(id = R.dimen.card_padding)) + ) { + Text( + style = MaterialTheme.typography.subtitle2, + text = dateText, + ) + HalfDialogInputSpacing() + if (noteInfo.featurePath.isNotBlank()) { + Text( + style = MaterialTheme.typography.body1, + text = noteInfo.featurePath, + color = MaterialTheme.tngColors.onSurface.copy(alpha = 0.5f) + ) + HalfDialogInputSpacing() + } + Text( + style = MaterialTheme.typography.body1, + text = noteInfo.note, + maxLines = 3 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/notes/NotesViewModel.kt b/app/src/main/java/com/samco/trackandgraph/notes/NotesViewModel.kt new file mode 100644 index 000000000..4202cc7b9 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/notes/NotesViewModel.kt @@ -0,0 +1,140 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.notes + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import com.samco.trackandgraph.base.database.dto.DisplayNote +import com.samco.trackandgraph.base.database.dto.GlobalNote +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.ui.compose.ui.Datable +import com.samco.trackandgraph.ui.compose.ui.DateDisplayResolution +import com.samco.trackandgraph.ui.compose.ui.DateScrollData +import com.samco.trackandgraph.util.FeaturePathProvider +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import org.threeten.bp.Duration +import org.threeten.bp.OffsetDateTime +import javax.inject.Inject + +data class NoteInfo( + override val date: OffsetDateTime, + val trackerId: Long?, + val featureId: Long?, + val featureName: String?, + val featurePath: String, + val groupId: Long?, + val note: String +) : Datable { + fun toDisplayNote(): DisplayNote = DisplayNote( + timestamp = date, + trackerId = trackerId, + featureId = featureId, + featureName = featureName, + groupId = groupId, + note = note + ) +} + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class NotesViewModel @Inject constructor( + private val dataInteractor: DataInteractor, + @IODispatcher private val io: CoroutineDispatcher +) : ViewModel() { + private val notesFlow = dataInteractor.getAllDisplayNotes() + .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) + + private val featureNameProvider = flow { + val trackers = dataInteractor.getAllTrackersSync() + val groups = dataInteractor.getAllGroupsSync() + emit(FeaturePathProvider(trackers, groups)) + }.flowOn(io) + + val dateScrollData: LiveData> = notesFlow + .filter { it.isNotEmpty() } + .combine(featureNameProvider) { list, featurePathProvider -> + val range = Duration + .between(list.last().timestamp, list.first().timestamp) + .abs() + + val dateDisplayResolution = when { + range.toDays() > 365 -> DateDisplayResolution.MONTH_YEAR + else -> DateDisplayResolution.MONTH_DAY + } + + return@combine DateScrollData( + items = list.map { it.asNoteInfo(featurePathProvider) }, + dateDisplayResolution = dateDisplayResolution + ) + } + .asLiveData(viewModelScope.coroutineContext) + + //Emits true and then false any time the top note in the list is changed so that the + // view knows to scroll back to the top of the list + val onNoteInsertedTop: LiveData = + notesFlow.scan(Pair(null, null)) { acc, value -> + //Every time there's a new list of notes get the top note and save it along with + // the top note from the last list + return@scan Pair(acc.second, value.firstOrNull()) + }.flatMapLatest { + //If the top note from the last list is not the same as the top note from this list + // emit true/false to scroll to the top + return@flatMapLatest if (it.first != it.second) flow { + emit(true) + emit(false) + } else emptyFlow() + }.asLiveData(viewModelScope.coroutineContext) + + + fun deleteNote(note: NoteInfo) = viewModelScope.launch(io) { + note.trackerId?.let { + dataInteractor.removeNote(note.date, it) + } ?: run { + dataInteractor.deleteGlobalNote(GlobalNote(note.date, note.note)) + } + } + + private fun DisplayNote.asNoteInfo( + featurePathProvider: FeaturePathProvider, + ) = NoteInfo( + date = timestamp, + trackerId = trackerId, + featureId = featureId, + featureName = featureName, + featurePath = featureId + ?.let { featurePathProvider.getPathForFeature(it) } + ?: featureName + ?: "", + groupId = groupId, + note = note + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/permissions/PermissionRequesterUseCase.kt b/app/src/main/java/com/samco/trackandgraph/permissions/PermissionRequesterUseCase.kt new file mode 100644 index 000000000..3381d6035 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/permissions/PermissionRequesterUseCase.kt @@ -0,0 +1,75 @@ +package com.samco.trackandgraph.permissions + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment + +interface PermissionRequesterUseCase { + fun initNotificationsPermissionRequester(fragment: Fragment) + fun requestNotificationPermission(context: Context) + fun requestAlarmAndNotificationPermission(context: Context) +} + +class PermissionRequesterUseCaseImpl : PermissionRequesterUseCase { + private lateinit var singlePermissionRequester: ActivityResultLauncher + private lateinit var multiplePermissionRequester: ActivityResultLauncher> + + override fun initNotificationsPermissionRequester(fragment: Fragment) { + singlePermissionRequester = + fragment.registerForActivityResult(ActivityResultContracts.RequestPermission()) {} + multiplePermissionRequester = + fragment.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { } + } + + override fun requestNotificationPermission(context: Context) { + if (Build.VERSION.SDK_INT >= 33) { + val notificationPermission = Manifest.permission.POST_NOTIFICATIONS + val permissionStatus = ContextCompat + .checkSelfPermission(context, notificationPermission) + if (permissionStatus != PackageManager.PERMISSION_GRANTED) { + singlePermissionRequester.launch(notificationPermission) + } + } + } + + override fun requestAlarmAndNotificationPermission(context: Context) { + if (Build.VERSION.SDK_INT >= 33) requestBothAlarmAndNotificationPermission(context) + else if (Build.VERSION.SDK_INT >= 31) requestAlarmPermission(context) + } + + @SuppressLint("InlinedApi") + private fun requestAlarmPermission(context: Context) { + val alarmPermission = Manifest.permission.SCHEDULE_EXACT_ALARM + val alarmPermissionStatus = ContextCompat + .checkSelfPermission(context, alarmPermission) + if (alarmPermissionStatus != PackageManager.PERMISSION_GRANTED) { + singlePermissionRequester.launch(alarmPermission) + } + } + + @SuppressLint("InlinedApi") + private fun requestBothAlarmAndNotificationPermission(context: Context) { + val notificationPermission = Manifest.permission.POST_NOTIFICATIONS + val alarmPermission = Manifest.permission.SCHEDULE_EXACT_ALARM + val notificationPermissionStatus = ContextCompat + .checkSelfPermission(context, notificationPermission) + val alarmPermissionStatus = ContextCompat + .checkSelfPermission(context, alarmPermission) + if (notificationPermissionStatus != PackageManager.PERMISSION_GRANTED + || alarmPermissionStatus != PackageManager.PERMISSION_GRANTED + ) { + multiplePermissionRequester.launch( + listOf( + notificationPermission, + alarmPermission + ).toTypedArray(), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/rateapp/RateAppRedirectFragment.kt b/app/src/main/java/com/samco/trackandgraph/rateapp/RateAppRedirectFragment.kt new file mode 100644 index 000000000..fc05218db --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/rateapp/RateAppRedirectFragment.kt @@ -0,0 +1,38 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.rateapp + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.navigation.findNavController +import com.samco.trackandgraph.R + +class RateAppRedirectFragment : Fragment() { + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val navController = container?.findNavController() + val url = getString(R.string.play_store_page_link) + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + startActivity(browserIntent) + navController?.popBackStack() + return super.onCreateView(inflater, container, savedInstanceState) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/reminders/ReminderListAdapter.kt b/app/src/main/java/com/samco/trackandgraph/reminders/ReminderListAdapter.kt new file mode 100644 index 000000000..769ebace5 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/reminders/ReminderListAdapter.kt @@ -0,0 +1,227 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.reminders + +import android.app.TimePickerDialog +import android.view.LayoutInflater +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.core.widget.addTextChangedListener +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.samco.trackandgraph.base.database.dto.* +import com.samco.trackandgraph.databinding.ListItemReminderBinding +import org.threeten.bp.LocalTime +import org.threeten.bp.ZoneId +import org.threeten.bp.format.DateTimeFormatter + +internal class ReminderListAdapter( + private val clickListener: ReminderClickListener +) : RecyclerView.Adapter() { + + private val reminders = mutableListOf() + + override fun onBindViewHolder(holder: ReminderViewHolder, position: Int) { + holder.bind(reminders[position]) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ReminderViewHolder { + return ReminderViewHolder.from(parent, clickListener) + } + + fun moveItem(fromPosition: Int, toPosition: Int) { + val item = reminders.removeAt(fromPosition) + reminders.add(toPosition, item) + super.notifyItemMoved(fromPosition, toPosition) + } + + fun getItems(): List = reminders.toList() + + fun submitList(newReminders: List) { + if (reminders.size != newReminders.size) { + //If an item was added or removed we want to animate the transition + val diffCallback = ListDiffCallback(reminders, newReminders) + val diffResult = DiffUtil.calculateDiff(diffCallback) + this.reminders.clear() + this.reminders.addAll(newReminders) + diffResult.dispatchUpdatesTo(this) + } else if (reminders.zip(newReminders).any { it.first.id != it.second.id }) { + //If the ids have changed for any reason we need to align the view holders + // so they send commands with the correct item id + this.reminders.clear() + this.reminders.addAll(newReminders) + notifyDataSetChanged() + } else { + //Otherwise update our list but don't tell the view holders, they will have made + // the same update to themselves when they requested the update and we don't want + // un-necessary glitches/animations while typing for example + this.reminders.clear() + this.reminders.addAll(newReminders) + } + } + + override fun getItemCount(): Int = reminders.size +} + +private class ListDiffCallback( + private val oldList: List, + private val newList: List +) : DiffUtil.Callback() { + override fun getOldListSize(): Int = oldList.size + + override fun getNewListSize(): Int = newList.size + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return oldList[oldItemPosition].id == newList[newItemPosition].id + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return oldList[oldItemPosition] == newList[newItemPosition] + } +} + + +internal class ReminderViewHolder private constructor( + private val binding: ListItemReminderBinding, + private val clickListener: ReminderClickListener +) : RecyclerView.ViewHolder(binding.root) { + + private val timeDisplayFormatter: DateTimeFormatter = DateTimeFormatter + .ofPattern("HH:mm") + .withZone(ZoneId.systemDefault()) + + private val checkboxes = listOf( + binding.monBox, binding.tueBox, binding.wedBox, + binding.thuBox, binding.friBox, binding.satBox, binding.sunBox + ) + + private var dropElevation = 0f + private var reminder: Reminder? = null + + init { + dropElevation = binding.cardView.cardElevation + binding.deleteButton.setOnClickListener { + reminder?.let { clickListener.delete(it) } + } + listenToCheckboxes() + listenToTimeButton() + listenToName() + } + + fun bind(reminder: Reminder) { + this.reminder = reminder + setTime(reminder.time) + setReminderNameText(reminder.alarmName) + setCheckedDays(reminder.checkedDays) + } + + private fun setCheckedDays(checkedDays: CheckedDays) { + val boolList = checkedDays.toList() + checkboxes.forEachIndexed { index, checkBox -> + checkBox.isChecked = boolList[index] + } + } + + private fun listenToName() { + binding.reminderNameText.addTextChangedListener(afterTextChanged = { + reminder = reminder?.copy(alarmName = it.toString()) + reminder?.let { rem -> clickListener.nameChanged(rem, it.toString()) } + }) + binding.reminderNameText.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + binding.cardView.requestFocus() + clickListener.hideKeyboard() + return@setOnEditorActionListener true + } + return@setOnEditorActionListener false + } + } + + private fun setReminderNameText(text: String) { + if (binding.reminderNameText.text.toString() != text) { + reminder = reminder?.copy(alarmName = text) + binding.reminderNameText.setText(text) + binding.reminderNameText.setSelection(text.length) + } + } + + private fun setTime(t: LocalTime) { + if (reminder?.time != t) reminder = reminder?.copy(time = t) + val text = t.format(timeDisplayFormatter) + if (binding.timeText.text != text) binding.timeText.text = text + } + + private fun listenToTimeButton() { + binding.timeText.setOnClickListener { + val picker = TimePickerDialog( + binding.root.context, + { _, hour, minute -> + val time = LocalTime.of(hour, minute) + setTime(time) + clickListener.timeChanged(reminder!!, time) + }, reminder!!.time.hour, reminder!!.time.minute, true + ) + picker.show() + } + } + + private fun listenToCheckboxes() { + checkboxes.forEach { cb -> + cb.setOnCheckedChangeListener { _, _ -> + val boolList = checkboxes.map { x -> x.isChecked } + val newCheckedDays = CheckedDays.fromList(boolList) + reminder = reminder?.copy(checkedDays = newCheckedDays) + reminder?.let { clickListener.daysChanged(it, newCheckedDays) } + } + } + } + + fun elevateCard() { + binding.cardView.postDelayed({ + binding.cardView.cardElevation = binding.cardView.cardElevation * 3f + }, 10) + } + + fun dropCard() { + binding.cardView.cardElevation = dropElevation + } + + companion object { + fun from(parent: ViewGroup, clickListener: ReminderClickListener): ReminderViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = ListItemReminderBinding.inflate(layoutInflater, parent, false) + return ReminderViewHolder(binding, clickListener) + } + } +} + +internal class ReminderClickListener( + private val onDelete: (Reminder) -> Unit, + private val onDaysChanged: (Reminder, CheckedDays) -> Unit, + private val onTimeChanged: (Reminder, LocalTime) -> Unit, + private val onNameChanged: (Reminder, String) -> Unit, + private val onHideKeyboard: () -> Unit +) { + fun delete(reminder: Reminder) = onDelete(reminder) + fun daysChanged(reminder: Reminder, checkedDays: CheckedDays) = + onDaysChanged(reminder, checkedDays) + + fun timeChanged(reminder: Reminder, localTime: LocalTime) = onTimeChanged(reminder, localTime) + fun nameChanged(reminder: Reminder, name: String) = onNameChanged(reminder, name) + fun hideKeyboard() = onHideKeyboard() +} diff --git a/app/src/main/java/com/samco/trackandgraph/reminders/RemindersFragment.kt b/app/src/main/java/com/samco/trackandgraph/reminders/RemindersFragment.kt new file mode 100644 index 000000000..5857358ea --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/reminders/RemindersFragment.kt @@ -0,0 +1,302 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.reminders + +import android.os.Bundle +import android.view.* +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.* +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.samco.trackandgraph.MainActivity +import com.samco.trackandgraph.NavButtonStyle +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.CheckedDays +import com.samco.trackandgraph.base.database.dto.Reminder +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.base.model.di.MainDispatcher +import com.samco.trackandgraph.databinding.RemindersFragmentBinding +import com.samco.trackandgraph.permissions.PermissionRequesterUseCase +import com.samco.trackandgraph.permissions.PermissionRequesterUseCaseImpl +import com.samco.trackandgraph.util.bindingForViewLifecycle +import com.samco.trackandgraph.util.hideKeyboard +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.threeten.bp.LocalTime +import javax.inject.Inject + +@AndroidEntryPoint +class RemindersFragment : Fragment(), + PermissionRequesterUseCase by PermissionRequesterUseCaseImpl() { + private var binding: RemindersFragmentBinding by bindingForViewLifecycle() + private val viewModel by viewModels() + private lateinit var adapter: ReminderListAdapter + + init { initNotificationsPermissionRequester(this) } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = RemindersFragmentBinding.inflate(inflater, container, false) + binding.lifecycleOwner = viewLifecycleOwner + + adapter = ReminderListAdapter( + ReminderClickListener( + viewModel::deleteReminder, + viewModel::daysChanged, + viewModel::onTimeChanged, + viewModel::onNameChanged, + this::onHideKeyboard + ) + ) + binding.remindersList.adapter = adapter + ItemTouchHelper(getDragTouchHelper()).attachToRecyclerView(binding.remindersList) + binding.remindersList.layoutManager = + LinearLayoutManager(context, RecyclerView.VERTICAL, false) + binding.remindersList.itemAnimator = DefaultItemAnimator() + registerForContextMenu(binding.remindersList) + + setHasOptionsMenu(true) + + return binding.root + } + + override fun onResume() { + super.onResume() + (requireActivity() as MainActivity).setActionBarConfig( + NavButtonStyle.MENU, + getString(R.string.reminders) + ) + } + + override fun onStart() { + super.onStart() + observeAndUpdateReminders() + observeSaveChangesButton() + observeLoading() + } + + private fun observeLoading() { + viewModel.loading.observe(viewLifecycleOwner) { + binding.loadingOverlay.visibility = if (it) View.VISIBLE else View.GONE + } + } + + private fun observeSaveChangesButton() { + binding.saveChangesButton.setOnClickListener { viewModel.saveChanges() } + viewModel.remindersChanged.observe(viewLifecycleOwner) { + binding.saveChangesButton.visibility = if (it) View.VISIBLE else View.GONE + } + } + + private fun onHideKeyboard() = requireActivity().window.hideKeyboard() + + private fun getDragTouchHelper() = object : ItemTouchHelper.Callback() { + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + return makeFlag( + ItemTouchHelper.ACTION_STATE_DRAG, + ItemTouchHelper.UP or ItemTouchHelper.DOWN + ) + } + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + adapter.moveItem(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition) + return true + } + + override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { + super.onSelectedChanged(viewHolder, actionState) + if (viewHolder != null && viewHolder is ReminderViewHolder && actionState == ItemTouchHelper.ACTION_STATE_DRAG) { + viewHolder.elevateCard() + } + } + + override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + super.clearView(recyclerView, viewHolder) + (viewHolder as ReminderViewHolder).dropCard() + viewModel.adjustDisplayIndexes(adapter.getItems()) + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} + } + + private fun observeAndUpdateReminders() { + viewModel.currentReminders.observe(viewLifecycleOwner) { + + if (it.isEmpty()) binding.noRemindersHintText.visibility = View.VISIBLE + else binding.noRemindersHintText.visibility = View.GONE + + adapter.submitList(it) + } + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.reminders_menu, menu) + super.onCreateOptionsMenu(menu, inflater) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == R.id.add_reminder) onAddClicked() + return super.onOptionsItemSelected(item) + } + + private fun onAddClicked() { + viewModel.addReminder(getString(R.string.default_reminder_name)) + viewModel.currentReminders.value?.size?.let { + binding.remindersList.smoothScrollToPosition(it) + } + requestAlarmAndNotificationPermission(requireContext()) + } + + override fun onPause() { + onHideKeyboard() + super.onPause() + } +} + +@HiltViewModel +class RemindersViewModel @Inject constructor( + private val dataInteractor: DataInteractor, + @IODispatcher private val io: CoroutineDispatcher, + @MainDispatcher private val ui: CoroutineDispatcher +) : ViewModel() { + + private val _currentReminders = MutableLiveData>(emptyList()) + val currentReminders: LiveData> = _currentReminders + + private var savedReminders: List = emptyList() + + private val _remindersChanged = MutableLiveData(false) + private val _loading = MutableLiveData(true) + + val remindersChanged: LiveData = _remindersChanged + val loading: LiveData = _loading + + init { + viewModelScope.launch(io) { + withContext(ui) { _loading.value = true } + savedReminders = dataInteractor.getAllRemindersSync() + val current = savedReminders.toMutableList() //Copy the list + withContext(ui) { + _currentReminders.value = current + _loading.value = false + } + } + } + + fun saveChanges() { + viewModelScope.launch(io) { + withContext(ui) { _loading.value = true } + _currentReminders.value?.let { + val withDisplayIndices = it + .mapIndexed { index, reminder -> reminder.copy(displayIndex = index) } + dataInteractor.updateReminders(withDisplayIndices) + savedReminders = dataInteractor.getAllRemindersSync() + withContext(ui) { + _currentReminders.value = savedReminders.toMutableList() + onRemindersUpdated() + } + } + withContext(ui) { _loading.value = false } + } + } + + private fun onRemindersUpdated() { + val a = savedReminders + val b = currentReminders.value ?: return + //If the two lists are not equal we have an update + _remindersChanged.value = !(a.size == b.size && a.zip(b).all { it.first == it.second }) + } + + fun addReminder(defaultName: String) { + _currentReminders.value = _currentReminders.value?.let { + val newReminder = Reminder( + //We just want a unique ID for now, + // this won't be used when it's added to the db + System.nanoTime(), + getNextDisplayIndex(), + defaultName, + LocalTime.now(), + CheckedDays.none() + ) + it.toMutableList().apply { add(newReminder) } + } + onRemindersUpdated() + } + + private fun getNextDisplayIndex(): Int { + return _currentReminders.value?.let { + if (it.isEmpty()) 0 + else it.maxOf { r -> r.displayIndex } + 1 + } ?: 0 + } + + fun deleteReminder(reminder: Reminder) { + _currentReminders.value = _currentReminders.value?.let { reminders -> + reminders.filter { it.id != reminder.id } + } + onRemindersUpdated() + } + + fun adjustDisplayIndexes(indexUpdate: List) { + _currentReminders.value = _currentReminders.value?.let { curr -> + indexUpdate.mapIndexed { i, r -> + curr.firstOrNull { it.id == r.id }?.copy(displayIndex = i) + }.filterNotNull() + } + onRemindersUpdated() + } + + fun daysChanged(reminder: Reminder, checkedDays: CheckedDays) = + updateReminder(reminder, reminder.copy(checkedDays = checkedDays)) + + fun onTimeChanged(reminder: Reminder, localTime: LocalTime) = + updateReminder(reminder, reminder.copy(time = localTime)) + + fun onNameChanged(reminder: Reminder, name: String) = + updateReminder(reminder, reminder.copy(alarmName = name)) + + private fun updateReminder(from: Reminder, to: Reminder) { + _currentReminders.value = _currentReminders.value?.let { reminders -> + val mutable = reminders.toMutableList() + val index = mutable.indexOfFirst { it.id == from.id } + if (index >= 0) { + mutable.removeAt(index) + mutable.add(index, to) + } + mutable + } + onRemindersUpdated() + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/selectgraphstatgroup/SelectGraphStatGroupFragment.kt b/app/src/main/java/com/samco/trackandgraph/selectgraphstatgroup/SelectGraphStatGroupFragment.kt deleted file mode 100644 index aaf12f5ac..000000000 --- a/app/src/main/java/com/samco/trackandgraph/selectgraphstatgroup/SelectGraphStatGroupFragment.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.samco.trackandgraph.selectgraphstatgroup - -import android.app.Activity -import android.view.View -import androidx.core.content.ContextCompat.getColor -import androidx.lifecycle.LiveData -import androidx.lifecycle.Observer -import androidx.lifecycle.ViewModelProviders -import com.samco.trackandgraph.R -import com.samco.trackandgraph.database.GraphStatGroup -import com.samco.trackandgraph.database.MAX_GRAPH_STAT_GROUP_NAME_LENGTH -import com.samco.trackandgraph.ui.* -import kotlinx.coroutines.launch - -class SelectGraphStatGroupFragment : SelectGroupFragment() { - override fun getCornerTabColor(): Int = getColor(context!!, R.color.secondaryColor) - - override fun getViewModelImpl(): SelectGroupViewModel { - return ViewModelProviders.of(this).get(SelectGraphStatGroupViewModel::class.java) - } - - override fun getRenameDialogHint() = getString(R.string.graph_stat_group_name) - - override fun getDeleteDialogTitle() = getString(R.string.ru_sure_del_graph_stat_group) - - override fun onGroupSelected(groupItem: GroupItem) { - navController?.navigate( - SelectGraphStatGroupFragmentDirections.actionSelectGraphStatGroup(groupItem.id) - ) - } - - override fun getGroupNameMaxLength(): Int = MAX_GRAPH_STAT_GROUP_NAME_LENGTH - - override fun getAddGroupHint() = getString(R.string.graph_stat_group_name) - - override fun getAddGroupTitle() = getString(R.string.add_graph_stat_group) - - override fun observeGroupDataAndUpdate(selectGroupViewModel: SelectGroupViewModel, adapter: GroupListAdapter) { - super.observeGroupDataAndUpdate(selectGroupViewModel, adapter) - val viewModel = selectGroupViewModel as SelectGraphStatGroupViewModel - viewModel.graphStatGroups.observe(viewLifecycleOwner, Observer { - it?.let { - adapter.submitList(it.map { gsg -> GroupItem(gsg.id, gsg.name, gsg.displayIndex) }.toMutableList()) - } - if (it.isNullOrEmpty()) { - binding.noGroupsHintText.text = getString(R.string.no_graph_stat_groups_hint) - binding.noGroupsHintText.visibility = View.VISIBLE - } else binding.noGroupsHintText.visibility = View.INVISIBLE - }) - } -} - -class SelectGraphStatGroupViewModel : SelectGroupViewModel() { - lateinit var graphStatGroups: LiveData> - - override fun initViewModel(activity: Activity) { - super.initViewModel(activity) - graphStatGroups = dataSource!!.getGraphStatGroups() - } - - override fun getNumGroups(): Int { - return graphStatGroups.value?.size ?: 0 - } - - override fun deleteGroup(groupItem: GroupItem) { - ioScope.launch { - dataSource!!.deleteGraphStatGroup(toGSG(groupItem)) - } - } - - override fun addGroup(groupItem: GroupItem) { - ioScope.launch { - dataSource!!.insertGraphStatGroup(toGSG(groupItem)) - } - } - - override fun updateGroup(groupItem: GroupItem) { - ioScope.launch { - dataSource!!.updateGraphStatGroup(toGSG(groupItem)) - } - } - - override fun adjustDisplayIndexes(groupItems: List) { - ioScope.launch { - graphStatGroups.value?.let { oldList -> - val newList = groupItems.mapIndexed { i, gi -> - oldList.first { tg -> tg.id == gi.id }.copy(displayIndex = i) - } - dataSource!!.updateGraphStatGroups(newList) - } - } - } - - private fun toGSG(gi: GroupItem) = GraphStatGroup.create(gi.id, gi.name, gi.displayIndex) -} diff --git a/app/src/main/java/com/samco/trackandgraph/selecttrackgroup/SelectTrackGroupFragment.kt b/app/src/main/java/com/samco/trackandgraph/selecttrackgroup/SelectTrackGroupFragment.kt deleted file mode 100644 index 26e41d384..000000000 --- a/app/src/main/java/com/samco/trackandgraph/selecttrackgroup/SelectTrackGroupFragment.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.samco.trackandgraph.selecttrackgroup - -import android.app.Activity -import android.view.View -import androidx.core.content.ContextCompat.getColor -import androidx.lifecycle.LiveData -import androidx.lifecycle.Observer -import androidx.lifecycle.ViewModelProviders -import com.samco.trackandgraph.R -import com.samco.trackandgraph.database.MAX_TRACK_GROUP_NAME_LENGTH -import com.samco.trackandgraph.database.TrackGroup -import com.samco.trackandgraph.ui.* -import kotlinx.coroutines.* - -class SelectTrackGroupFragment : SelectGroupFragment() { - override fun getCornerTabColor(): Int = getColor(context!!, R.color.primaryColor) - - override fun getViewModelImpl(): SelectGroupViewModel { - return ViewModelProviders.of(this).get(SelectTrackGroupViewModel::class.java) - } - - override fun getRenameDialogHint() = getString(R.string.track_group_name) - - override fun getDeleteDialogTitle() = getString(R.string.ru_sure_del_track_group) - - override fun getAddGroupHint() = getString(R.string.track_group_name) - - override fun getAddGroupTitle() = getString(R.string.add_track_group) - - override fun getGroupNameMaxLength() = MAX_TRACK_GROUP_NAME_LENGTH - - override fun observeGroupDataAndUpdate(selectGroupViewModel: SelectGroupViewModel, - adapter: GroupListAdapter) { - super.observeGroupDataAndUpdate(selectGroupViewModel, adapter) - val viewModel = (selectGroupViewModel as SelectTrackGroupViewModel) - viewModel.trackGroups.observe(viewLifecycleOwner, Observer { - it?.let { - adapter.submitList(it.map { tg -> GroupItem(tg.id, tg.name, tg.displayIndex) }.toMutableList()) - } - if (it.isNullOrEmpty()) { - binding.noGroupsHintText.text = getString(R.string.no_track_groups_hint) - binding.noGroupsHintText.visibility = View.VISIBLE - } else binding.noGroupsHintText.visibility = View.INVISIBLE - }) - } - - override fun onGroupSelected(groupItem: GroupItem) { - navController?.navigate( - SelectTrackGroupFragmentDirections - .actionSelectTackGroup(groupItem.id, groupItem.name) - ) - } -} - -class SelectTrackGroupViewModel : SelectGroupViewModel() { - lateinit var trackGroups: LiveData> - - override fun initViewModel(activity: Activity) { - super.initViewModel(activity) - trackGroups = dataSource!!.getTrackGroups() - } - - override fun getNumGroups(): Int { - return trackGroups.value?.size ?: 0 - } - - override fun deleteGroup(groupItem: GroupItem) { - ioScope.launch { - dataSource!!.deleteTrackGroup(toTG(groupItem)) - } - } - - override fun addGroup(groupItem: GroupItem) { - ioScope.launch { - dataSource!!.insertTrackGroup(toTG(groupItem)) - } - } - - override fun updateGroup(groupItem: GroupItem) { - ioScope.launch { - dataSource!!.updateTrackGroup(toTG(groupItem)) - } - } - - override fun adjustDisplayIndexes(groupItems: List) { - ioScope.launch { - trackGroups.value?.let { oldList -> - val newList = groupItems.mapIndexed { i, gi -> - oldList.first { tg -> tg.id == gi.id }.copy(displayIndex = i) - } - dataSource!!.updateTrackGroups(newList) - } - } - } - - private fun toTG(gi: GroupItem) = TrackGroup.create(gi.id, gi.name, gi.displayIndex) -} diff --git a/app/src/main/java/com/samco/trackandgraph/settings/TngSettings.kt b/app/src/main/java/com/samco/trackandgraph/settings/TngSettings.kt new file mode 100644 index 000000000..a5ae55638 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/settings/TngSettings.kt @@ -0,0 +1,9 @@ +package com.samco.trackandgraph.settings + +import org.threeten.bp.DayOfWeek +import org.threeten.bp.Duration + +interface TngSettings { + val firstDayOfWeek: DayOfWeek + val startTimeOfDay: Duration +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/settings/TngSettingsImpl.kt b/app/src/main/java/com/samco/trackandgraph/settings/TngSettingsImpl.kt new file mode 100644 index 000000000..f7e8b93c6 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/settings/TngSettingsImpl.kt @@ -0,0 +1,30 @@ +package com.samco.trackandgraph.settings + +import android.content.Context +import android.os.Build +import dagger.hilt.android.qualifiers.ApplicationContext +import org.threeten.bp.DayOfWeek +import org.threeten.bp.Duration +import org.threeten.bp.temporal.WeekFields +import java.util.Locale +import javax.inject.Inject + +//TODO this is a copy paste of the AggregationPreferences .. Need to align this so it's all +// in one place. I think that place is here, but we need to be able to inject this where it's +// needed in the factories which might require some refactoring, so there's a duplicate for now. +class TngSettingsImpl @Inject constructor( + @ApplicationContext private val context: Context +) : TngSettings { + override val firstDayOfWeek: DayOfWeek + get() = WeekFields.of(getLocale()).firstDayOfWeek + override val startTimeOfDay: Duration + get() = Duration.ofSeconds(0) + + private fun getLocale(): Locale = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + context.resources.configuration.locales.get(0) + } else { + @Suppress("DEPRECATION") + context.resources.configuration.locale + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/timers/AddDataPointFromTimerActivity.kt b/app/src/main/java/com/samco/trackandgraph/timers/AddDataPointFromTimerActivity.kt new file mode 100644 index 000000000..a44a824c4 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/timers/AddDataPointFromTimerActivity.kt @@ -0,0 +1,106 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.timers + +import android.os.Bundle +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.samco.trackandgraph.adddatapoint.AddDataPointsDialog +import com.samco.trackandgraph.adddatapoint.AddDataPointsViewModelImpl +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.settings.TngSettings +import com.samco.trackandgraph.ui.compose.compositionlocals.LocalSettings +import com.samco.trackandgraph.util.hideKeyboard +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.launch +import org.threeten.bp.Duration +import org.threeten.bp.Instant +import javax.inject.Inject + +@AndroidEntryPoint +class AddDataPointFromTimerActivity : AppCompatActivity() { + + companion object { + const val TRACKER_ID_KEY = "TRACKER_ID_KEY" + const val START_TIME_KEY = "START_TIME_KEY" + } + + private val viewModel by viewModels() + + private val addDataPointsViewModel by viewModels() + + @Inject + lateinit var tngSettings: TngSettings + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val composeView = ComposeView(this).apply { + setContent { + CompositionLocalProvider(LocalSettings provides tngSettings) { + AddDataPointsDialog(viewModel = addDataPointsViewModel) { finish() } + } + } + } + setContentView(composeView) + + val bundle = intent.extras + val trackerId = bundle?.getLong(TRACKER_ID_KEY) + val startTimeStr = bundle?.getString(START_TIME_KEY) + + if (trackerId == null || startTimeStr == null) { + finish() + } else { + val startInstant = Instant.parse(startTimeStr) + val duration = Duration.between(startInstant, Instant.now()).seconds + viewModel.stopTimer(trackerId) + showDialog(trackerId, duration) + } + } + + private fun showDialog(trackerId: Long, duration: Long) { + addDataPointsViewModel.showAddDataPointDialog( + trackerId, + customInitialValue = duration.toDouble() + ) + } + + override fun onDestroy() { + super.onDestroy() + window.hideKeyboard() + } +} + +@HiltViewModel +class AddDataPointFromTimerViewModel @Inject constructor( + private val dataInteractor: DataInteractor, + @IODispatcher private val io: CoroutineDispatcher +) : ViewModel() { + fun stopTimer(trackerId: Long) { + viewModelScope.launch(io) { + dataInteractor.stopTimerForTracker(trackerId) + } + } + +} diff --git a/app/src/main/java/com/samco/trackandgraph/tutorial/TutorialPagerAdapter.kt b/app/src/main/java/com/samco/trackandgraph/tutorial/TutorialPagerAdapter.kt new file mode 100644 index 000000000..8e9f0813c --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/tutorial/TutorialPagerAdapter.kt @@ -0,0 +1,52 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.tutorial + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.viewpager.widget.PagerAdapter +import com.samco.trackandgraph.R + +class TutorialPagerAdapter( + private val context: Context, + private val closeTutorialCallback: () -> Unit) +: PagerAdapter() { + + override fun instantiateItem(collection: ViewGroup, position: Int): Any { + val resId = when (position) { + 0 -> R.layout.tutorial_page_1 + 1 -> R.layout.tutorial_page_2 + else -> R.layout.tutorial_page_3 + } + val inflater = LayoutInflater.from(context) + val layout = inflater.inflate(resId, collection, false) as ViewGroup + val closeButton = layout.findViewById(R.id.closeButton) + closeButton?.setOnClickListener { closeTutorialCallback() } + collection.addView(layout) + return layout + } + + override fun destroyItem(collection: ViewGroup, position: Int, view: Any) { + collection.removeView(view as View?) + } + + override fun getCount() = 3 + + override fun isViewFromObject(view: View, `object`: Any) = view === `object` +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/ui/AddGroupDialogFragment.kt b/app/src/main/java/com/samco/trackandgraph/ui/AddGroupDialogFragment.kt deleted file mode 100644 index e9657baf1..000000000 --- a/app/src/main/java/com/samco/trackandgraph/ui/AddGroupDialogFragment.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.samco.trackandgraph.ui - -import androidx.fragment.app.Fragment -import com.samco.trackandgraph.R - - -class AddGroupDialogFragment : NameInputDialogFragment() { - - private lateinit var listener: AddGroupDialogListener - - interface AddGroupDialogListener { - fun onAddGroup(name: String) - fun getAddGroupHintText(): String - fun getAddGroupTitleText(): String - fun getGroupNameMaxLength(): Int - } - - override fun registerListener(parentFragment: Fragment?) { - listener = parentFragment as AddGroupDialogListener - } - - override fun getMaxChars(): Int = listener.getGroupNameMaxLength() - - override fun getPositiveButtonName(): String = getString(R.string.add) - - override fun onPositiveClicked(name: String) = listener.onAddGroup(name) - - override fun getNameInputHint(): String = listener.getAddGroupHintText() - - override fun getTitleText(): String = listener.getAddGroupTitleText() - - override fun getNameInputText(): String = "" -} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/ui/DataVisColorList.kt b/app/src/main/java/com/samco/trackandgraph/ui/DataVisColorList.kt new file mode 100644 index 000000000..427de77a7 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/DataVisColorList.kt @@ -0,0 +1,37 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.ui + +import com.samco.trackandgraph.R + +//this is a number coprime to the number of colours used to select them in a pseudo random order for greater contrast +const val dataVisColorGenerator = 7 +val dataVisColorList = listOf( + R.color.visColor1, + R.color.visColor2, + R.color.visColor3, + R.color.visColor4, + R.color.visColor5, + R.color.visColor6, + R.color.visColor7, + R.color.visColor8, + R.color.visColor9, + R.color.visColor10, + R.color.visColor11, + R.color.visColor12 +) diff --git a/app/src/main/java/com/samco/trackandgraph/ui/DescriptionDialogs.kt b/app/src/main/java/com/samco/trackandgraph/ui/DescriptionDialogs.kt new file mode 100644 index 000000000..45624520d --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/DescriptionDialogs.kt @@ -0,0 +1,128 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.ui + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.* +import com.samco.trackandgraph.base.helpers.formatDayMonthYearHourMinuteWeekDayOneLine +import com.samco.trackandgraph.base.helpers.getDisplayValue +import com.samco.trackandgraph.base.helpers.getWeekDayNames +import com.samco.trackandgraph.databinding.DescriptionBodyTextBinding +import com.samco.trackandgraph.databinding.ShowNoteDialogHeaderBinding +import org.threeten.bp.OffsetDateTime + +fun showFeatureDescriptionDialog(context: Context, name: String, description: String) { + val descriptionOrNone = description.ifEmpty { context.getString(R.string.no_description) } + + val descriptionView = LayoutInflater.from(context) + .inflate(R.layout.feature_description_layout, null, false) + + descriptionView.findViewById(R.id.tv_title).text = name + descriptionView.findViewById(R.id.tv_description).text = descriptionOrNone + + AlertDialog.Builder(context) + .setView(descriptionView) + .create() + .show() +} + +private fun getBodyTextView(context: Context, text: String): View { + val bodyView = DescriptionBodyTextBinding.inflate(LayoutInflater.from(context)) + bodyView.text.text = text + return bodyView.root +} + +fun showNoteDialog(inflater: LayoutInflater, context: Context, note: GlobalNote) { + val headerView = getNoteDialogHeader(inflater, context, note.timestamp, null, null) + AlertDialog.Builder(context) + .setCustomTitle(headerView) + .setView(getBodyTextView(context, note.note)) + .create() + .show() +} + +fun showNoteDialog( + inflater: LayoutInflater, + context: Context, + note: DisplayNote, + featurePath: String? +) { + val headerView = + getNoteDialogHeader(inflater, context, note.timestamp, null, featurePath) + AlertDialog.Builder(context) + .setCustomTitle(headerView) + .setView(getBodyTextView(context, note.note)) + .create() + .show() +} + +private fun getNoteDialogHeader( + inflater: LayoutInflater, + context: Context, + timestamp: OffsetDateTime, + displayValue: String?, + featureDisplayName: String? +): View { + val headerView = ShowNoteDialogHeaderBinding.inflate(inflater) + headerView.dateTimeText.text = + formatDayMonthYearHourMinuteWeekDayOneLine(context, getWeekDayNames(context), timestamp) + headerView.valueText.visibility = if (displayValue.isNullOrEmpty()) View.GONE else View.VISIBLE + displayValue?.let { headerView.valueText.text = it } + headerView.featureDisplayNameText.visibility = + if (featureDisplayName.isNullOrEmpty()) View.GONE else View.VISIBLE + featureDisplayName?.let { headerView.featureDisplayNameText.text = it } + return headerView.root +} + +fun showDataPointDescriptionDialog( + context: Context, + inflater: LayoutInflater, + dataPoint: DataPoint, + isDuration: Boolean, + featureDispalayName: String? = null +) { + showDataPointDescriptionDialog( + context, + inflater, + dataPoint.timestamp, + dataPoint.getDisplayValue(isDuration), + dataPoint.note, + featureDispalayName + ) +} + +fun showDataPointDescriptionDialog( + context: Context, inflater: LayoutInflater, timestamp: OffsetDateTime, displayValue: String, + note: String, featureDispalayName: String? = null +) { + val headerView = + getNoteDialogHeader(inflater, context, timestamp, displayValue, featureDispalayName) + + val bodyView = getBodyTextView(context, note) + + AlertDialog.Builder(context) + .setCustomTitle(headerView) + .setView(bodyView) + .create() + .show() +} diff --git a/app/src/main/java/com/samco/trackandgraph/ui/ExtendedSpinner.kt b/app/src/main/java/com/samco/trackandgraph/ui/ExtendedSpinner.kt new file mode 100644 index 000000000..1396777fc --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/ExtendedSpinner.kt @@ -0,0 +1,39 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.ui + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatSpinner + +class ExtendedSpinner(context: Context, attrs: AttributeSet?) : AppCompatSpinner(context, attrs) { + interface OnItemClickedListener { + fun onItemClicked(index: Int) + } + + var listener: OnItemClickedListener? = null + + override fun setSelection(position: Int) { + super.setSelection(position) + if (listener != null) listener!!.onItemClicked(position) + } + + fun setOnItemClickedListener(listener: OnItemClickedListener?) { + this.listener = listener + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/ui/GraphLegendItemView.kt b/app/src/main/java/com/samco/trackandgraph/ui/GraphLegendItemView.kt index 4a94951a0..0362c56b4 100644 --- a/app/src/main/java/com/samco/trackandgraph/ui/GraphLegendItemView.kt +++ b/app/src/main/java/com/samco/trackandgraph/ui/GraphLegendItemView.kt @@ -1,16 +1,44 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ package com.samco.trackandgraph.ui import android.content.Context +import android.util.AttributeSet import android.view.LayoutInflater import android.widget.FrameLayout -import androidx.core.content.ContextCompat.getColor -import com.samco.trackandgraph.database.dataVisColorList import com.samco.trackandgraph.databinding.ListItemGraphLegendBinding -class GraphLegendItemView(context: Context, colorId: Int, text: String): FrameLayout(context) { - private var binding = ListItemGraphLegendBinding.inflate(LayoutInflater.from(context), this, true) - init { - binding.circleImage.setColorFilter(getColor(context, colorId)) - binding.labelText.text = text - } +class GraphLegendItemView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context) { + private val binding = ListItemGraphLegendBinding + .inflate(LayoutInflater.from(context), this, true) + + var color: Int = 0 + set(value) { + binding.circleImage.setColorFilter(value) + field = value + } + + var text: String = "" + set(value) { + binding.labelText.text = value + field = value + } } \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/ui/GraphStatCardView.kt b/app/src/main/java/com/samco/trackandgraph/ui/GraphStatCardView.kt deleted file mode 100644 index fde43bdd9..000000000 --- a/app/src/main/java/com/samco/trackandgraph/ui/GraphStatCardView.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.samco.trackandgraph.ui - -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import androidx.cardview.widget.CardView -import com.samco.trackandgraph.databinding.GraphStatCardViewBinding -import com.samco.trackandgraph.databinding.GraphStatViewBinding - -class GraphStatCardView : GraphStatViewBase { - private lateinit var binding: GraphStatCardViewBinding - lateinit var cardView: CardView - private set - - constructor(context: Context) : super(context) - constructor(context: Context, attrSet: AttributeSet) : super(context, attrSet) - - var menuButtonClickListener: ((v: View) -> Unit)? = null - - init { - listenToMenuButton() - } - - private fun listenToMenuButton() { - binding.menuButton.setOnClickListener { - menuButtonClickListener?.invoke(binding.menuButton) - } - } - - fun hideMenuButton() { - binding.menuButton.visibility = View.GONE - } - - override fun getBinding(): GraphStatViewBinding { - binding = GraphStatCardViewBinding.inflate(LayoutInflater.from(context), this, true) - cardView = binding.demoCardView - return binding.graphStatView - } -} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/ui/GraphStatScrollView.kt b/app/src/main/java/com/samco/trackandgraph/ui/GraphStatScrollView.kt deleted file mode 100644 index b1ba00a29..000000000 --- a/app/src/main/java/com/samco/trackandgraph/ui/GraphStatScrollView.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.samco.trackandgraph.ui - -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import com.samco.trackandgraph.databinding.GraphStatScrollViewBinding -import com.samco.trackandgraph.databinding.GraphStatViewBinding - - -open class GraphStatScrollView : GraphStatViewBase { - constructor(context: Context) : super(context) - constructor(context: Context, attrSet: AttributeSet) : super(context, attrSet) - - override fun getBinding(): GraphStatViewBinding { - return GraphStatScrollViewBinding.inflate(LayoutInflater.from(context), this, true).graphStatView - } -} - diff --git a/app/src/main/java/com/samco/trackandgraph/ui/GraphStatViewBase.kt b/app/src/main/java/com/samco/trackandgraph/ui/GraphStatViewBase.kt deleted file mode 100644 index 276d90a4a..000000000 --- a/app/src/main/java/com/samco/trackandgraph/ui/GraphStatViewBase.kt +++ /dev/null @@ -1,481 +0,0 @@ -package com.samco.trackandgraph.ui - -import android.content.Context -import android.graphics.Color -import android.util.AttributeSet -import android.view.View -import android.widget.FrameLayout -import androidx.core.content.ContextCompat.getColor -import com.androidplot.Plot -import com.androidplot.Region -import com.androidplot.pie.PieRenderer -import com.androidplot.pie.Segment -import com.androidplot.pie.SegmentFormatter -import com.androidplot.ui.HorizontalPositioning -import com.androidplot.ui.Size -import com.androidplot.ui.VerticalPositioning -import com.androidplot.util.SeriesUtils -import com.androidplot.xy.* -import com.samco.trackandgraph.databinding.GraphStatViewBinding -import kotlinx.coroutines.* -import com.samco.trackandgraph.R -import com.samco.trackandgraph.database.* -import org.threeten.bp.Duration -import org.threeten.bp.OffsetDateTime -import org.threeten.bp.Period -import org.threeten.bp.ZoneId -import org.threeten.bp.format.DateTimeFormatter -import org.threeten.bp.temporal.TemporalAdjusters -import org.threeten.bp.temporal.WeekFields -import java.text.FieldPosition -import java.text.Format -import java.text.ParsePosition -import java.util.* -import kotlin.math.abs - -abstract class GraphStatViewBase : FrameLayout { - constructor(context: Context) : super(context, null) - constructor(context: Context, attrSet: AttributeSet) : super(context, attrSet) - - private var binding = getBinding() - private var currJob: Job? = null - private var viewScope: CoroutineScope? = null - private val dataSource = TrackAndGraphDatabase.getInstance(context.applicationContext).trackAndGraphDatabaseDao - private val creationTime = OffsetDateTime.now() - private var listViewMode = false - - private val currentXYRegions = mutableListOf() - - private val lineGraphHourMinuteSecondFromat: DateTimeFormatter = DateTimeFormatter - .ofPattern("HH:mm:ss") - .withZone(ZoneId.systemDefault()) - private val lineGraphHoursDateFormat: DateTimeFormatter = DateTimeFormatter - .ofPattern("HH:mm") - .withZone(ZoneId.systemDefault()) - private val lineGraphDaysDateFormat: DateTimeFormatter = DateTimeFormatter - .ofPattern("dd/MM") - .withZone(ZoneId.systemDefault()) - private val lineGraphMonthsDateFormat: DateTimeFormatter = DateTimeFormatter - .ofPattern("MM/yy") - .withZone(ZoneId.systemDefault()) - - protected abstract fun getBinding(): GraphStatViewBinding - - init { - basicLineGraphSetup() - initInvalid() - } - - private fun resetJob() { - currJob?.cancel() - currJob = Job() - viewScope = CoroutineScope(Dispatchers.Main + currJob!!) - } - - private fun basicLineGraphSetup() { - binding.lineGraph.layoutManager.remove(binding.lineGraph.domainTitle) - binding.lineGraph.layoutManager.remove(binding.lineGraph.rangeTitle) - binding.lineGraph.layoutManager.remove(binding.lineGraph.title) - binding.lineGraph.setBorderStyle(Plot.BorderStyle.NONE, null, null) - binding.lineGraph.graph.size = Size.FILL - binding.lineGraph.graph.position(0f, HorizontalPositioning.ABSOLUTE_FROM_LEFT, 0f, VerticalPositioning.ABSOLUTE_FROM_TOP) - binding.lineGraph.setPlotMargins(0f, 0f, 0f, 0f) - binding.lineGraph.setPlotPadding(0f, 0f, 0f, 0f) - binding.lineGraph.graph.setPadding(0f,0f,0f,0f) - binding.lineGraph.graph.setMargins(0f, 20f, 0f, 50f) - } - - fun addLineGraphPanAndZoom() { - PanZoom.attach(binding.lineGraph, PanZoom.Pan.HORIZONTAL, PanZoom.Zoom.STRETCH_HORIZONTAL) - } - - private fun cleanAllViews() { - binding.legendFlexboxLayout.removeAllViews() - currentXYRegions.clear() - binding.lineGraph.clear() - binding.pieChart.clear() - binding.progressBar.visibility = View.GONE - binding.lineGraph.visibility = View.GONE - binding.pieChart.visibility = View.GONE - binding.errorMessage.visibility = View.GONE - binding.statMessage.visibility = View.GONE - binding.errorMessage.text = "" - binding.headerText.text = "" - binding.statMessage.text = "" - } - - fun initLoading() { - resetJob() - cleanAllViews() - binding.progressBar.visibility = View.VISIBLE - } - - fun initInvalid() { - resetJob() - cleanAllViews() - initErrorTextView(null, R.string.graph_stat_view_invalid_setup) - } - - fun initError(graphOrStat: GraphOrStat?, errorTextId: Int) { - resetJob() - initErrorTextView(graphOrStat, errorTextId) - } - - private fun initErrorTextView(graphOrStat: GraphOrStat?, errorTextId: Int) { - cleanAllViews() - graphOrStat?.let { initHeader(graphOrStat) } - binding.errorMessage.visibility = View.VISIBLE - binding.errorMessage.text = context.getString(errorTextId) - } - - private fun initHeader(graphOrStat: GraphOrStat) { - binding.headerText.text = graphOrStat.name - } - - fun initTimeSinceStat(graphOrStat: GraphOrStat, timeSinceLastStat: TimeSinceLastStat) { - resetJob() - cleanAllViews() - binding.statMessage.visibility = View.INVISIBLE - initHeader(graphOrStat) - viewScope!!.launch { - binding.progressBar.visibility = View.VISIBLE - val feature = withContext(Dispatchers.IO) { dataSource.getFeatureById(timeSinceLastStat.featureId) } - var lastDataPointTimeStamp: OffsetDateTime? = null - val lastDataPoint = withContext(Dispatchers.IO) { - dataSource.getLastDataPointBetween(feature.id, timeSinceLastStat.fromValue, timeSinceLastStat.toValue) - } - if (lastDataPoint != null) lastDataPointTimeStamp = lastDataPoint.timestamp - binding.statMessage.visibility = View.VISIBLE - binding.progressBar.visibility = View.GONE - if (lastDataPointTimeStamp == null) { initErrorTextView(graphOrStat, R.string.graph_stat_view_not_enough_data_stat) } - else { - while (true) { - setTimeSinceStatText(Duration.between(lastDataPointTimeStamp, OffsetDateTime.now())) - delay(1000) - yield() - } - } - } - } - - fun initAverageTimeBetweenStat(graphOrStat: GraphOrStat, timeBetweenStat: AverageTimeBetweenStat) { - resetJob() - cleanAllViews() - binding.statMessage.visibility = View.INVISIBLE - initHeader(graphOrStat) - viewScope!!.launch { - binding.progressBar.visibility = View.VISIBLE - val feature = withContext(Dispatchers.IO) { dataSource.getFeatureById(timeBetweenStat.featureId) } - val dataPoints = withContext(Dispatchers.IO) { - if (timeBetweenStat.duration == null) { - dataSource.getDataPointsBetween(feature.id, timeBetweenStat.fromValue, timeBetweenStat.toValue) - } else { - val now = OffsetDateTime.now() - val cutOff = now.minus(timeBetweenStat.duration) - dataSource.getDataPointsBetweenInTimeRange(feature.id, timeBetweenStat.fromValue, timeBetweenStat.toValue, cutOff, now) - } - } - if (dataPoints.size < 2) initErrorTextView(graphOrStat, R.string.graph_stat_view_not_enough_data_stat) - else { - val totalMillis = Duration.between(dataPoints.first().timestamp, dataPoints.last().timestamp).toMillis().toDouble() - val averageMillis = totalMillis / dataPoints.size.toDouble() - setAverageTimeBetweenStatText(averageMillis) - } - - binding.statMessage.visibility = View.VISIBLE - binding.progressBar.visibility = View.GONE - } - } - - private fun setAverageTimeBetweenStatText(millis: Double) { - val days = "%.1f".format((millis / 86400000).toFloat()) - binding.statMessage.text = "$days ${context.getString(R.string.days)}" - } - - private fun setTimeSinceStatText(duration: Duration) { - val totalSeconds = duration.toMillis() / 1000.toDouble() - val daysNum = (totalSeconds / 86400).toInt() - val days = daysNum.toString() - val hours = "%02d".format(((totalSeconds % 86400) / 3600).toInt()) - val minutes = "%02d".format(((totalSeconds % 3600) / 60).toInt()) - val seconds = "%02d".format((totalSeconds % 60).toInt()) - val hms = "$hours:$minutes:$seconds" - binding.statMessage.text = - when { - daysNum == 1 -> "$days ${context.getString(R.string.day)}\n$hms" - daysNum > 0 -> "$days ${context.getString(R.string.days)}\n$hms" - else -> hms - } - } - - fun initFromLineGraph(graphOrStat: GraphOrStat, lineGraph: LineGraph, listViewMode: Boolean = false) { - this.listViewMode = listViewMode - resetJob() - cleanAllViews() - binding.lineGraph.visibility = View.INVISIBLE - initHeader(graphOrStat) - viewScope!!.launch { - binding.progressBar.visibility = View.VISIBLE - if (tryDrawLineGraphFeaturesAndCacheTimeRange(lineGraph)) { - setUpLineGraphXAxis() - val bounds = RectRegion() - currentXYRegions.forEach { r -> bounds.union(r) } - binding.lineGraph.outerLimits.set(bounds.minX, bounds.maxX, bounds.minY, bounds.maxY) - binding.lineGraph.redraw() - binding.lineGraph.visibility = View.VISIBLE - binding.progressBar.visibility = View.GONE - } else { - initErrorTextView(graphOrStat, R.string.graph_stat_view_not_enough_data_graph) - } - } - } - - fun initFromPieChart(graphOrStat: GraphOrStat, pieChart: PieChart) { - resetJob() - cleanAllViews() - binding.pieChart.visibility = View.INVISIBLE - initHeader(graphOrStat) - viewScope!!.launch { - binding.progressBar.visibility = View.VISIBLE - val dataSample = tryGetPlottableDataForPieChart(pieChart) - if (dataSample == null) { - initErrorTextView(graphOrStat, R.string.graph_stat_view_not_enough_data_graph) - return@launch - } - val segments = getPieChartSegments(dataSample) - val total = withContext(Dispatchers.IO) { - segments.sumByDouble { s -> s.value.toDouble() } - } - plotPieChartSegments(segments, total) - binding.pieChart.redraw() - binding.pieChart.getRenderer(PieRenderer::class.java).setDonutSize(0f, PieRenderer.DonutMode.PERCENT) - binding.pieChart.visibility = View.VISIBLE - binding.progressBar.visibility = View.GONE - } - } - - private fun plotPieChartSegments(segments: List, total: Double) { - segments.forEachIndexed { i, s -> - val index = (dataVisColorGenerator * i) % dataVisColorList.size - val colorId = dataVisColorList[index] - val segForm = SegmentFormatter(getColor(context, colorId)) - segForm.labelPaint.color = Color.TRANSPARENT - val percentage = "%.1f".format((s.value.toDouble() / total) * 100f) - inflateGraphLegendItem(index, "${s.title} ($percentage%)") - binding.pieChart.addSegment(s, segForm) - } - } - - private suspend fun getPieChartSegments(dataSample: RawDataSample) = withContext(Dispatchers.IO) { - dataSample.dataPoints - .drop(dataSample.plotFrom) - .groupingBy { dp -> dp.label } - .eachCount() - .map { b -> Segment(b.key, b.value) } - } - - private suspend fun tryGetPlottableDataForPieChart(pieChart: PieChart): RawDataSample? { - val feature = withContext(Dispatchers.IO) { dataSource.getFeatureById(pieChart.featureId) } - val dataSample = sampleData(feature.id, pieChart.duration, null, null) - return if (dataPlottable(dataSample)) dataSample else null - } - - private suspend fun tryDrawLineGraphFeaturesAndCacheTimeRange(lineGraph: LineGraph): Boolean { - return lineGraph.features.map { - yield() - drawLineGraphFeature(lineGraph, it) - }.any() - } - - private fun setUpLineGraphXAxis() { - binding.lineGraph.graph.getLineLabelStyle(XYGraphWidget.Edge.BOTTOM).format = object : Format() { - override fun format(obj: Any, toAppendTo: StringBuffer, pos: FieldPosition): StringBuffer { - val millis = (obj as Number).toLong() - val duration = Duration.ofMillis(millis) - val timeStamp = creationTime.plus(duration) - val formatter = getDateTimeFormatForDuration( - binding.lineGraph.bounds.minX, - binding.lineGraph.bounds.maxX - ) - return toAppendTo.append(formatter.format(timeStamp)) - } - override fun parseObject(source: String, pos: ParsePosition) = null - } - } - - private fun getDateTimeFormatForDuration(minX: Number?, maxX: Number?): DateTimeFormatter { - if (minX == null || maxX == null) return lineGraphDaysDateFormat - val duration = Duration.ofMillis(abs(maxX.toLong() - minX.toLong())) - return when { - duration.toMinutes() < 5L -> lineGraphHourMinuteSecondFromat - duration.toDays() >= 304 -> lineGraphMonthsDateFormat - duration.toDays() >= 1 -> lineGraphDaysDateFormat - else -> lineGraphHoursDateFormat - } - } - - private class RawDataSample(val dataPoints: List, val plotFrom: Int) - - private suspend fun drawLineGraphFeature(lineGraph: LineGraph, lineGraphFeature: LineGraphFeature): Boolean { - inflateGraphLegendItem(lineGraphFeature.colorIndex, lineGraphFeature.name) - val movingAvDuration = movingAverageDurations[lineGraphFeature.averagingMode] - val plottingPeriod = plottingModePeriods[lineGraphFeature.plottingMode] - val rawDataSample = sampleData(lineGraphFeature.featureId, lineGraph.duration, movingAvDuration, plottingPeriod) - val plottingData = withContext(Dispatchers.IO) { - when (lineGraphFeature.plottingMode) { - LineGraphPlottingModes.WHEN_TRACKED -> rawDataSample - else -> calculateDurationAccumulatedValues(rawDataSample, plottingPeriod!!) - } - } - return if (dataPlottable(plottingData)) { - createAndAddSeries(plottingData, lineGraphFeature) - true - } else false - } - - private fun inflateGraphLegendItem(colorIndex: Int, label: String) { - val colorId = dataVisColorList[colorIndex] - binding.legendFlexboxLayout.addView(GraphLegendItemView(context, colorId, label)) - } - - private fun dataPlottable(rawData: RawDataSample): Boolean { - return rawData.plotFrom >= 0 - && rawData.dataPoints.size - rawData.plotFrom > 1 - && rawData.dataPoints[rawData.plotFrom].timestamp.isBefore(rawData.dataPoints.last().timestamp) - } - - private suspend fun sampleData(featureId: Long, sampleDuration: Duration?, - averagingDuration: Duration?, plottingPeriod: Period?): RawDataSample { - return withContext(Dispatchers.IO) { - if (sampleDuration == null) RawDataSample(dataSource.getDataPointsForFeatureAscSync(featureId), 0) - else { - val now = OffsetDateTime.now() - val startDate = now.minus(sampleDuration) - val plottingDuration = plottingPeriod?.let { Duration.between(now, now.minus(plottingPeriod)) } - val maxSampleDuration = listOf( - sampleDuration, - averagingDuration?.plus(sampleDuration), - plottingDuration - ).maxBy { d -> d ?: Duration.ZERO } - val minSampleDate = now.minus(maxSampleDuration) - val dataPoints = dataSource.getDataPointsForFeatureBetweenAscSync(featureId, minSampleDate, now) - val startIndex = dataPoints.indexOfFirst { dp -> dp.timestamp.isAfter(startDate) } - RawDataSample(dataPoints, startIndex) - } - } - } - - private suspend fun createAndAddSeries(rawData: RawDataSample, lineGraphFeature: LineGraphFeature) { - val series = getXYSeriesFromRawDataSample(rawData, lineGraphFeature) - addSeries(series, lineGraphFeature) - } - - private fun addSeries(series: FastXYSeries, lineGraphFeature: LineGraphFeature) { - val seriesFormat = - if (listViewMode) { - val sf = FastLineAndPointRenderer.Formatter( - getColor(context, dataVisColorList[lineGraphFeature.colorIndex]), - null, - null - ) - sf.linePaint.isAntiAlias = false - sf.linePaint.strokeWidth = 2f * resources.displayMetrics.density - sf - } else { - val sf = LineAndPointFormatter(context, R.xml.line_point_formatter) - sf.linePaint.color = getColor(context, dataVisColorList[lineGraphFeature.colorIndex]) - sf - } - currentXYRegions.add(series.minMax()) - binding.lineGraph.addSeries(series, seriesFormat) - } - - private suspend fun getXYSeriesFromRawDataSample(rawData: RawDataSample, lineGraphFeature: LineGraphFeature) - = withContext(Dispatchers.IO) { - - val yValues = when (lineGraphFeature.averagingMode) { - LineGraphAveraginModes.NO_AVERAGING -> rawData.dataPoints.drop(rawData.plotFrom).map { dp -> dp.value.toDouble() } - else -> calculateMovingAverage(rawData, movingAverageDurations[lineGraphFeature.averagingMode]!!) - }.map { v -> (v * lineGraphFeature.scale) + lineGraphFeature.offset } - - val xValues = rawData.dataPoints.drop(rawData.plotFrom).map { - dp -> Duration.between(creationTime, dp.timestamp).toMillis() - } - - var yRegion = SeriesUtils.minMax(yValues) - if (abs(yRegion.min.toDouble() - yRegion.max.toDouble()) < 0.1) - yRegion = Region(yRegion.min, yRegion.min.toDouble() + 0.1) - var xRegion = SeriesUtils.minMax(xValues) - val rectRegion = RectRegion(xRegion.min, xRegion.max, yRegion.min, yRegion.max) - - return@withContext object: FastXYSeries { - override fun minMax() = rectRegion - override fun getX(index: Int): Number = xValues[index] - override fun getY(index: Int): Number = yValues[index] - override fun getTitle() = lineGraphFeature.name - override fun size() = rawData.dataPoints.size - rawData.plotFrom - } - } - - private suspend fun calculateDurationAccumulatedValues(rawData: RawDataSample, period: Period): RawDataSample { - var plotFrom = 0 - var foundPlotFrom = false - val featureId = rawData.dataPoints[0].featureId - val newData = mutableListOf() - var currentTimeStamp = findBeginningOfPeriod(rawData.dataPoints[0].timestamp, period) - val now = OffsetDateTime.now() - var index = 0 - while (currentTimeStamp.isBefore(now)) { - currentTimeStamp = currentTimeStamp.with {ld -> ld.plus(period)} - val points = rawData.dataPoints.drop(index).takeWhile { dp -> dp.timestamp.isBefore(currentTimeStamp) } - val total = points.sumByDouble { dp -> dp.value.toDouble() }.toString() - index += points.size - if (index > rawData.plotFrom && !foundPlotFrom) { - plotFrom = newData.size - foundPlotFrom = true - } - newData.add(DataPoint(currentTimeStamp, featureId, total, "")) - yield() - } - return RawDataSample(newData, plotFrom) - } - - private fun findBeginningOfPeriod(startDateTime: OffsetDateTime, period: Period): OffsetDateTime { - var dt = startDateTime - val minusAWeek = period.minus(Period.ofWeeks(1)) - val minusAMonth = period.minus(Period.ofMonths(1)) - val minusAYear = period.minus(Period.ofYears(1)) - if (minusAYear.days >= 0 && !minusAYear.isNegative) { - dt = startDateTime.withDayOfYear(1) - } - else if (minusAMonth.days >= 0 && !minusAMonth.isNegative) { - dt = startDateTime - .withDayOfMonth(1) - } - else if (minusAWeek.days >= 0 && !minusAWeek.isNegative) { - dt = startDateTime.with(TemporalAdjusters.previousOrSame( - WeekFields.of(Locale.getDefault()).firstDayOfWeek - )) - } - return dt.withHour(0).withMinute(0).withSecond(0).minusSeconds(1) - } - - private suspend fun calculateMovingAverage(rawData: RawDataSample, movingAvDuration: Duration): List { - return rawData.dataPoints - .drop(rawData.plotFrom) - .mapIndexed { index, dataPoint -> - val inRange = mutableListOf(dataPoint) - var i = rawData.plotFrom+index-1 - while (i > 0 && Duration.between(rawData.dataPoints[i].timestamp, dataPoint.timestamp) <= movingAvDuration) { - inRange.add(rawData.dataPoints[i]) - i-- - } - yield() - inRange.sumByDouble { dp -> dp.value.toDouble() } / inRange.size.toDouble() - } - } - - fun dispose() { currJob?.cancel() } -} - - diff --git a/app/src/main/java/com/samco/trackandgraph/ui/GroupListAdapter.kt b/app/src/main/java/com/samco/trackandgraph/ui/GroupListAdapter.kt deleted file mode 100644 index 977251396..000000000 --- a/app/src/main/java/com/samco/trackandgraph/ui/GroupListAdapter.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.samco.trackandgraph.ui - -import android.view.* -import android.widget.PopupMenu -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.samco.trackandgraph.R -import com.samco.trackandgraph.databinding.ListItemGroupBinding - -class GroupListAdapter(private val clickListener: GroupClickListener, private val cornerTabColor: Int) - : OrderedListAdapter(GroupItemDiffCallback()) { - override fun onBindViewHolder(holder: GroupViewHolder, position: Int) { - holder.bind(getItem(position), clickListener, cornerTabColor) - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GroupViewHolder { - return GroupViewHolder.from(parent) - } -} - -class GroupViewHolder private constructor(private val binding: ListItemGroupBinding) - : RecyclerView.ViewHolder(binding.root), PopupMenu.OnMenuItemClickListener { - private var clickListener: GroupClickListener? = null - private var dropElevation = 0f - var groupItem: GroupItem? = null - private set - - fun bind(groupItem: GroupItem, clickListener: GroupClickListener, cornerTabColor: Int) { - this.groupItem = groupItem - this.clickListener = clickListener - dropElevation = binding.cardView.cardElevation - binding.graphStatGroupNameText.text = groupItem.name - binding.cardView.setOnClickListener { clickListener.onClick(groupItem) } - binding.menuButton.setOnClickListener { createContextMenu(binding.menuButton) } - binding.cornerTabImage.setColorFilter(cornerTabColor) - } - - fun elevateCard() { - binding.cardView.postDelayed({ - binding.cardView.cardElevation = binding.cardView.cardElevation * 3f - }, 10) - } - - fun dropCard() { - binding.cardView.cardElevation = dropElevation - } - - private fun createContextMenu(view: View) { - val popup = PopupMenu(view.context, view) - popup.menuInflater.inflate(R.menu.edit_group_context_menu, popup.menu) - popup.setOnMenuItemClickListener(this) - popup.show() - } - - override fun onMenuItemClick(item: MenuItem?): Boolean { - groupItem?.let { - when (item?.itemId) { - R.id.rename -> clickListener?.onRename(it) - R.id.delete -> clickListener?.onDelete(it) - else -> {} - } - } - return false - } - - companion object { - fun from(parent: ViewGroup): GroupViewHolder { - val layoutInflater = LayoutInflater.from(parent.context) - val binding = ListItemGroupBinding.inflate(layoutInflater, parent, false) - return GroupViewHolder(binding) - } - } -} - -class GroupItemDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: GroupItem, newItem: GroupItem): Boolean { - return oldItem.id == newItem.id - } - override fun areContentsTheSame(oldItem: GroupItem, newItem: GroupItem): Boolean { - return oldItem == newItem - } -} - -data class GroupItem (val id: Long, val name: String, val displayIndex: Int) - -class GroupClickListener(val clickListener: (groupItem: GroupItem) -> Unit, - val onRenameListener: (groupItem: GroupItem) -> Unit, - val onDeleteListener: (groupItem: GroupItem) -> Unit) { - fun onClick(groupItem: GroupItem) = clickListener(groupItem) - fun onRename(groupItem: GroupItem) = onRenameListener(groupItem) - fun onDelete(groupItem: GroupItem) = onDeleteListener(groupItem) -} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/ui/MoveToDialogFragment.kt b/app/src/main/java/com/samco/trackandgraph/ui/MoveToDialogFragment.kt new file mode 100644 index 000000000..4831f0f2b --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/MoveToDialogFragment.kt @@ -0,0 +1,198 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.database.dto.GraphOrStat +import com.samco.trackandgraph.base.database.dto.Group +import com.samco.trackandgraph.base.database.dto.Tracker +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.base.model.di.MainDispatcher +import com.samco.trackandgraph.databinding.ListItemMoveToGroupBinding +import com.samco.trackandgraph.databinding.MoveToGroupDialogBinding +import com.samco.trackandgraph.util.GroupPathProvider +import com.samco.trackandgraph.util.bindingForViewLifecycle +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.* +import timber.log.Timber +import java.lang.Exception +import javax.inject.Inject +import kotlin.math.min + +const val MOVE_DIALOG_TYPE_KEY = "move_dialog_type" +const val MOVE_DIALOG_GROUP_KEY = "move_dialog_group" +const val MOVE_DIALOG_TYPE_TRACK = "track" +const val MOVE_DIALOG_TYPE_GRAPH = "graph" +const val MOVE_DIALOG_TYPE_GROUP = "group" + +enum class MoveDialogType { TRACKER, GRAPH, GROUP } + +@AndroidEntryPoint +class MoveToDialogFragment : DialogFragment() { + private val viewModel by viewModels() + private var binding: MoveToGroupDialogBinding by bindingForViewLifecycle() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return activity?.let { + initViewModel() + binding = MoveToGroupDialogBinding.inflate(inflater, container, false) + binding.cancelButton.setOnClickListener { dismiss() } + dialog?.setCanceledOnTouchOutside(true) + binding.root + } ?: throw IllegalStateException("Activity cannot be null") + } + + override fun onResume() { + super.onResume() + Timber.d("setting layout on resume") + dialog?.window?.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + + private fun initViewModel() { + val mode = when (requireArguments().getString(MOVE_DIALOG_TYPE_KEY)) { + MOVE_DIALOG_TYPE_TRACK -> MoveDialogType.TRACKER + MOVE_DIALOG_TYPE_GRAPH -> MoveDialogType.GRAPH + MOVE_DIALOG_TYPE_GROUP -> MoveDialogType.GROUP + else -> throw Exception("Unrecognised move dialog mode") + } + val id = requireArguments().getLong(MOVE_DIALOG_GROUP_KEY) + viewModel.init(mode, id) + listenToViewModel() + } + + private fun listenToViewModel() { + viewModel.availableGroups.observe(viewLifecycleOwner) { + inflateGroupItems(it) + } + + viewModel.state.observe(viewLifecycleOwner) { + if (it == MoveToDialogState.MOVED) dismiss() + } + } + + private fun inflateGroupItems(groupPathProvider: GroupPathProvider) { + for (item in groupPathProvider.filteredGroups) { + val groupItemView = + ListItemMoveToGroupBinding.inflate(layoutInflater, binding.groupsLayout, false) + groupItemView.groupNameText.text = groupPathProvider.getPathForGroup(item.id) + groupItemView.itemBackground.setOnClickListener { viewModel.moveTo(item.id) } + binding.groupsLayout.addView(groupItemView.root) + } + setDialogHeight(groupPathProvider.groups.size) + } + + private fun setDialogHeight(numItems: Int) { + val itemSize = resources.getDimensionPixelSize(R.dimen.list_item_group_item_height) + val baseHeight = resources.getDimensionPixelSize(R.dimen.move_to_dialog_base_height) + val maxSize = resources.getDimensionPixelSize(R.dimen.move_to_dialog_max_height) + val height = min(baseHeight + (itemSize * numItems), maxSize) + val params = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + height + ) + binding.moveToDialogRoot.layoutParams = params + } +} + +enum class MoveToDialogState { INITIALIZING, WAITING, MOVING, MOVED } + +@HiltViewModel +class MoveToDialogViewModel @Inject constructor( + private val dataInteractor: DataInteractor, + @IODispatcher private val io: CoroutineDispatcher, + @MainDispatcher private val ui: CoroutineDispatcher +) : ViewModel() { + private lateinit var mode: MoveDialogType + + private val _state = MutableLiveData(MoveToDialogState.INITIALIZING) + val state: LiveData get() = _state + + private val _availableGroups = MutableLiveData() + val availableGroups: LiveData get() = _availableGroups + + private var tracker: Tracker? = null + private var graphStat: GraphOrStat? = null + private var group: Group? = null + + private var initialized = false + + fun init(mode: MoveDialogType, id: Long) { + if (initialized) return + initialized = true + + this.mode = mode + viewModelScope.launch(io) { + val groups = dataInteractor.getAllGroupsSync().toMutableList() + val groupPathProvider = GroupPathProvider( + groups = groups, + groupFilterId = if (mode == MoveDialogType.GROUP) id else null + ) + when (mode) { + MoveDialogType.TRACKER -> tracker = dataInteractor.getTrackerById(id) + MoveDialogType.GRAPH -> graphStat = dataInteractor.getGraphStatById(id) + MoveDialogType.GROUP -> group = dataInteractor.getGroupById(id) + } + withContext(ui) { + _availableGroups.value = groupPathProvider + _state.value = MoveToDialogState.WAITING + } + } + } + + fun moveTo(newGroupId: Long) = viewModelScope.launch(io) { + if (_state.value != MoveToDialogState.WAITING) return@launch + withContext(ui) { _state.value = MoveToDialogState.MOVING } + when (mode) { + MoveDialogType.TRACKER -> { + tracker?.copy(groupId = newGroupId)?.let { + dataInteractor.updateTracker(it) + } + } + MoveDialogType.GRAPH -> { + graphStat?.copy(groupId = newGroupId)?.let { + dataInteractor.updateGraphOrStat(it) + } + } + MoveDialogType.GROUP -> { + group?.copy(parentGroupId = newGroupId)?.let { + dataInteractor.updateGroup(it) + } + } + } + withContext(ui) { _state.value = MoveToDialogState.MOVED } + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/ui/NameInputDialogFragment.kt b/app/src/main/java/com/samco/trackandgraph/ui/NameInputDialogFragment.kt deleted file mode 100644 index ec873f50d..000000000 --- a/app/src/main/java/com/samco/trackandgraph/ui/NameInputDialogFragment.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.samco.trackandgraph.ui - -import android.app.AlertDialog -import android.app.Dialog -import android.os.Bundle -import android.text.Editable -import android.text.InputFilter -import android.text.TextWatcher -import android.view.WindowManager -import android.widget.EditText -import android.widget.TextView -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.Fragment -import com.samco.trackandgraph.R - -abstract class NameInputDialogFragment : DialogFragment(), TextWatcher { - private lateinit var editText: EditText - private lateinit var alertDialog: AlertDialog - - abstract fun registerListener(parentFragment: Fragment?) - abstract fun getPositiveButtonName() : String - abstract fun onPositiveClicked(name: String) - abstract fun getNameInputHint() : String - abstract fun getTitleText() : String - abstract fun getNameInputText() : String - abstract fun getMaxChars() : Int - - override fun onCreateDialog(savedInstanceState: Bundle?) : Dialog { - return activity?.let { - registerListener(parentFragment) - val view = it.layoutInflater.inflate(R.layout.name_input_dialog, null) - editText = view.findViewById(R.id.edit_name_input) - editText.hint = getNameInputHint() - editText.setText(getNameInputText()) - editText.setSelection(editText.text.length) - editText.addTextChangedListener(this) - editText.filters = arrayOf(InputFilter.LengthFilter(getMaxChars())) - view.findViewById(R.id.prompt_text).text = getTitleText() - var builder = AlertDialog.Builder(it) - builder.setView(view) - .setPositiveButton(getPositiveButtonName()) { _, _ -> onPositiveClicked(editText.text.toString()) } - .setNegativeButton(R.string.cancel) { _, _ -> {} } - alertDialog = builder.create() - alertDialog - } ?: throw IllegalStateException("Activity cannot be null") - } - - override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) { } - override fun afterTextChanged(p0: Editable?) { - p0?.let { - alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = p0.isNotEmpty() - } - } - - override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, length: Int) { } - - override fun onActivityCreated(savedInstanceState: Bundle?) { - super.onActivityCreated(savedInstanceState) - dialog?.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE) - } - - override fun onStart() { - super.onStart() - alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = editText.text.isNotEmpty() - } -} diff --git a/app/src/main/java/com/samco/trackandgraph/ui/OrderedListAdapter.kt b/app/src/main/java/com/samco/trackandgraph/ui/OrderedListAdapter.kt index bb4f094c6..7aec76c9d 100644 --- a/app/src/main/java/com/samco/trackandgraph/ui/OrderedListAdapter.kt +++ b/app/src/main/java/com/samco/trackandgraph/ui/OrderedListAdapter.kt @@ -1,13 +1,35 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ package com.samco.trackandgraph.ui import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView +//TODO we're keeping a duplicate list here with the parent list adapter, this could +// cause bugs. We basically should get rid of this base class and just implement adapter properly. abstract class OrderedListAdapter( + private val getId: (T) -> Long, diffCallback: DiffUtil.ItemCallback ) : ListAdapter(diffCallback) { + //TODO shouldn't do this either + init { setHasStableIds(true) } + private var list: MutableList = mutableListOf() fun moveItem(fromPosition: Int, toPosition: Int) { @@ -21,5 +43,9 @@ abstract class OrderedListAdapter( super.submitList(list) } + override fun getItemId(position: Int): Long { + return getId(getItem(position)) + } + fun getItems() = list } diff --git a/app/src/main/java/com/samco/trackandgraph/ui/RenameGroupDialogFragment.kt b/app/src/main/java/com/samco/trackandgraph/ui/RenameGroupDialogFragment.kt deleted file mode 100644 index dc2345096..000000000 --- a/app/src/main/java/com/samco/trackandgraph/ui/RenameGroupDialogFragment.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.samco.trackandgraph.ui - -import androidx.fragment.app.Fragment -import com.samco.trackandgraph.R - -class RenameGroupDialogFragment : NameInputDialogFragment() { - private lateinit var listener: RenameGroupDialogListener - private lateinit var groupItem: GroupItem - - interface RenameGroupDialogListener { - fun getGroupItem() : GroupItem - fun onRenameGroupItem(groupItem: GroupItem) - fun getRenameDialogHintText(): String - fun getGroupNameMaxLength(): Int - } - - override fun getMaxChars(): Int = listener.getGroupNameMaxLength() - - override fun registerListener(parentFragment: Fragment?) { - listener = parentFragment as RenameGroupDialogListener - groupItem = listener.getGroupItem() - } - - override fun getPositiveButtonName(): String { - return getString(R.string.rename) - } - - override fun onPositiveClicked(name: String) { - val newGroupItem = GroupItem(groupItem.id, name, groupItem.displayIndex) - listener.onRenameGroupItem(newGroupItem) - } - - override fun getNameInputHint() = listener.getRenameDialogHintText() - - override fun getTitleText(): String = getString(R.string.new_name) - - override fun getNameInputText(): String = groupItem.name -} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/ui/SelectGroupFragment.kt b/app/src/main/java/com/samco/trackandgraph/ui/SelectGroupFragment.kt deleted file mode 100644 index 1576aa48c..000000000 --- a/app/src/main/java/com/samco/trackandgraph/ui/SelectGroupFragment.kt +++ /dev/null @@ -1,182 +0,0 @@ -package com.samco.trackandgraph.ui - -import android.app.Activity -import android.os.Bundle -import android.view.* -import androidx.databinding.DataBindingUtil -import androidx.fragment.app.Fragment -import androidx.lifecycle.ViewModel -import androidx.navigation.NavController -import androidx.navigation.findNavController -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.samco.trackandgraph.R -import com.samco.trackandgraph.database.TrackAndGraphDatabase -import com.samco.trackandgraph.database.TrackAndGraphDatabaseDao -import com.samco.trackandgraph.databinding.FragmentSelectGroupBinding -import kotlinx.coroutines.* - -abstract class SelectGroupFragment : Fragment(), - YesCancelDialogFragment.YesCancelDialogListener, - AddGroupDialogFragment.AddGroupDialogListener, - RenameGroupDialogFragment.RenameGroupDialogListener -{ - protected var navController: NavController? = null - protected lateinit var binding: FragmentSelectGroupBinding - protected lateinit var viewModel: SelectGroupViewModel - protected lateinit var adapter: GroupListAdapter - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - this.navController = container?.findNavController() - binding = DataBindingUtil.inflate(inflater, - R.layout.fragment_select_group, container, false) - binding.lifecycleOwner = viewLifecycleOwner - - viewModel = getViewModelImpl() - viewModel.initViewModel(requireActivity()) - - adapter = GroupListAdapter( - GroupClickListener( - this::onGroupSelected, - this::onRenameClicked, - this::onDeleteClicked - ), - getCornerTabColor() - ) - binding.groupList.adapter = adapter - ItemTouchHelper(getDragTouchHelper()).attachToRecyclerView(binding.groupList) - binding.groupList.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) - registerForContextMenu(binding.groupList) - - observeGroupDataAndUpdate(viewModel, adapter) - setHasOptionsMenu(true) - return binding.root - } - - abstract fun getCornerTabColor(): Int - - abstract fun getViewModelImpl(): SelectGroupViewModel - - private fun getDragTouchHelper() = object : ItemTouchHelper.Callback() { - override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { - return makeFlag(ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN) - } - - override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { - adapter.moveItem(viewHolder.adapterPosition, target.adapterPosition) - return true - } - - override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { - super.onSelectedChanged(viewHolder, actionState) - if (viewHolder != null && viewHolder is GroupViewHolder && actionState == ItemTouchHelper.ACTION_STATE_DRAG) { - viewHolder.elevateCard() - } - } - - override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { - super.clearView(recyclerView, viewHolder) - (viewHolder as GroupViewHolder).dropCard() - viewModel.adjustDisplayIndexes(adapter.getItems()) - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { } - } - - protected open fun observeGroupDataAndUpdate(selectGroupViewModel: SelectGroupViewModel, - adapter: GroupListAdapter) { - adapter.registerAdapterDataObserver(object: RecyclerView.AdapterDataObserver() { - override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - binding.groupList.smoothScrollToPosition(0) - } - }) - } - - private fun onRenameClicked(groupItem: GroupItem) { - viewModel.currentActionGroupItem = groupItem - val dialog = RenameGroupDialogFragment() - childFragmentManager.let { dialog.show(it, "rename_group_dialog") } - } - - override fun getGroupItem() = viewModel.currentActionGroupItem!! - - override fun onRenameGroupItem(groupItem: GroupItem) = viewModel.updateGroup(groupItem) - override fun getRenameDialogHintText() = getRenameDialogHint() - abstract fun getRenameDialogHint(): String - - private fun onDeleteClicked(groupItem: GroupItem) { - viewModel.currentActionGroupItem = groupItem - val dialog = YesCancelDialogFragment() - val args = Bundle() - args.putString("title", getDeleteDialogTitle()) - dialog.arguments = args - childFragmentManager.let { dialog.show(it, "ru_sure_del_group_fragment") } - } - - abstract fun getDeleteDialogTitle(): String - - override fun onDialogYes(dialog: YesCancelDialogFragment) { - when (dialog.title) { - getDeleteDialogTitle() -> viewModel.currentActionGroupItem?.let { - viewModel.deleteGroup(it) - } - } - } - - abstract fun onGroupSelected(groupItem: GroupItem) - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.select_group_menu, menu) - super.onCreateOptionsMenu(menu, inflater) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.add -> onAddClicked() - } - return super.onOptionsItemSelected(item) - } - - private fun onAddClicked() { - val dialog = AddGroupDialogFragment() - childFragmentManager.let { dialog.show(it, "add_group_dialog") } - } - - override fun getAddGroupHintText() = getAddGroupHint() - abstract fun getAddGroupHint(): String - - override fun getAddGroupTitleText() = getAddGroupTitle() - abstract fun getAddGroupTitle(): String - - override fun onAddGroup(name: String) { - viewModel.addGroup( - GroupItem(0, name, viewModel.getNumGroups()) - ) - } -} - -abstract class SelectGroupViewModel : ViewModel() { - private var updateJob = Job() - protected val ioScope = CoroutineScope(Dispatchers.IO + updateJob) - - protected var dataSource: TrackAndGraphDatabaseDao? = null - var currentActionGroupItem: GroupItem? = null - - open fun initViewModel(activity: Activity) { - if (dataSource != null) return - dataSource = TrackAndGraphDatabase.getInstance(activity.application).trackAndGraphDatabaseDao - } - - //TODO check all view models to make sure they do this - override fun onCleared() { - super.onCleared() - ioScope.cancel() - } - - abstract fun getNumGroups(): Int - abstract fun deleteGroup(groupItem: GroupItem) - abstract fun addGroup(groupItem: GroupItem) - abstract fun updateGroup(groupItem: GroupItem) - abstract fun adjustDisplayIndexes(groupItems: List) -} diff --git a/app/src/main/java/com/samco/trackandgraph/ui/YesCancelDialogFragment.kt b/app/src/main/java/com/samco/trackandgraph/ui/YesCancelDialogFragment.kt index 4f0ae0eb7..ada840544 100644 --- a/app/src/main/java/com/samco/trackandgraph/ui/YesCancelDialogFragment.kt +++ b/app/src/main/java/com/samco/trackandgraph/ui/YesCancelDialogFragment.kt @@ -1,6 +1,22 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ package com.samco.trackandgraph.ui -import android.app.AlertDialog +import androidx.appcompat.app.AlertDialog import android.app.Dialog import android.os.Bundle import androidx.fragment.app.DialogFragment @@ -8,22 +24,39 @@ import com.samco.trackandgraph.R class YesCancelDialogFragment : DialogFragment() { lateinit var title: String - private lateinit var listener: YesCancelDialogListener + + companion object { + private const val TITLE_KEY = "title" + private const val ID_KEY = "id" + + fun create(id: String, title: String) = YesCancelDialogFragment().apply { + val args = Bundle() + args.putString(TITLE_KEY, title) + args.putString(ID_KEY, id) + arguments = args + } + } interface YesCancelDialogListener { - fun onDialogYes(dialog: YesCancelDialogFragment) + fun onDialogYes(dialog: YesCancelDialogFragment, id: String?) } - override fun onCreateDialog(savedInstanceState: Bundle?) : Dialog { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { title = arguments?.getString("title") ?: "" + val id = arguments?.getString("id") return activity?.let { - listener = parentFragment as YesCancelDialogListener - var builder = AlertDialog.Builder(it) - builder.setMessage(title) - .setPositiveButton(R.string.yes) { _, _ -> listener.onDialogYes(this) } - .setNegativeButton(R.string.cancel) { _, _ -> {} } - builder.create() - + val listener = parentFragment as YesCancelDialogListener + AlertDialog.Builder(it, R.style.AppTheme_AlertDialogTheme).apply { + setMessage(title) + .setCancelable(true) + .setPositiveButton(R.string.yes) { _, _ -> + listener.onDialogYes( + this@YesCancelDialogFragment, + id + ) + } + .setNegativeButton(R.string.cancel) { _, _ -> run {} } + }.create() } ?: throw IllegalStateException("Activity cannot be null") } } \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/compositionlocals/CompositionLocalProviders.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/compositionlocals/CompositionLocalProviders.kt new file mode 100644 index 000000000..6ab61fd1d --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/compositionlocals/CompositionLocalProviders.kt @@ -0,0 +1,8 @@ +package com.samco.trackandgraph.ui.compose.compositionlocals + +import androidx.compose.runtime.staticCompositionLocalOf +import com.samco.trackandgraph.settings.TngSettings + +val LocalSettings = staticCompositionLocalOf { + error("CompositionLocal LocalSettings not provided") +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/theming/TnGComposeTheme.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/theming/TnGComposeTheme.kt new file mode 100644 index 000000000..c2238b6e7 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/theming/TnGComposeTheme.kt @@ -0,0 +1,135 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.ui.compose.theming + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material.Colors +import androidx.compose.material.MaterialTheme +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import androidx.compose.runtime.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import com.google.android.material.composethemeadapter.createMdcTheme +import com.samco.trackandgraph.ui.compose.ui.shapes + +private val lightGray = Color(0xFFE0E0E0) +private val darkGray = Color(0xFF4C4C4C) +private val midCharcoal = Color(0xFF222222) + +data class TngColors( + val material: Colors, + val selectorButtonColor: Color, + val textColorSecondary: Color +) { + val primary get() = material.primary + val primaryVariant get() = material.primaryVariant + val secondary get() = material.secondary + val secondaryVariant get() = material.secondaryVariant + val background get() = material.background + val surface get() = material.surface + val error get() = material.error + val onPrimary get() = material.onPrimary + val onSecondary get() = material.onSecondary + val onBackground get() = material.onBackground + val onSurface get() = material.onSurface + val onError get() = material.onError + val isLight get() = material.isLight +} + + +private val LightColorPalette = TngColors( + material = lightColors(), + selectorButtonColor = lightGray, + textColorSecondary = darkGray +) + +private val DarkColorPalette = TngColors( + material = darkColors(), + selectorButtonColor = darkGray, + textColorSecondary = lightGray +) + +val TngColors.disabledAlpha get() = 0.4f + +private val LocalColors = staticCompositionLocalOf { LightColorPalette } + +val MaterialTheme.tngColors: TngColors + @Composable + @ReadOnlyComposable + get() = LocalColors.current + +@Composable +fun TnGComposeTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + block: @Composable () -> Unit +) { + val (materialColors, typography, _) = createMdcTheme( + context = LocalContext.current, + layoutDirection = LocalLayoutDirection.current + ) + + val colors = tngColors(darkTheme, materialColors) + + CompositionLocalProvider(LocalColors provides colors) { + MaterialTheme( + colors = colors.material, + typography = typography ?: MaterialTheme.typography, + shapes = shapes, + content = block + ) + } +} + +@Composable +fun DialogTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + block: @Composable () -> Unit +) = TnGComposeTheme(darkTheme) { + + val colors = MaterialTheme.tngColors.let { + if (darkTheme) { + it.copy( + material = MaterialTheme.tngColors.material.copy( + background = midCharcoal, + surface = midCharcoal, + ) + ) + } else it + } + + + CompositionLocalProvider(LocalColors provides colors) { + MaterialTheme( + colors = colors.material, + typography = MaterialTheme.typography, + shapes = MaterialTheme.shapes, + content = block + ) + } +} + +@Composable +private fun tngColors( + darkTheme: Boolean, + materialColors: Colors? +) = if (darkTheme) { + DarkColorPalette.copy(material = materialColors ?: darkColors()) +} else { + LightColorPalette.copy(material = materialColors ?: lightColors()) +} diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/AddCreateBar.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/AddCreateBar.kt new file mode 100644 index 000000000..3209dbc1f --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/AddCreateBar.kt @@ -0,0 +1,70 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.ui.compose.ui + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.samco.trackandgraph.R + +@Composable +fun AddCreateBar( + errorText: Int?, + onCreateUpdateClicked: () -> Unit, + isUpdateMode: Boolean +) = Surface( + modifier = Modifier.fillMaxWidth() +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + + Text( + text = errorText?.let { stringResource(id = it) } ?: "", + color = MaterialTheme.colors.error, + fontSize = MaterialTheme.typography.body2.fontSize, + fontWeight = MaterialTheme.typography.body2.fontWeight, + modifier = Modifier + .weight(1f) + .padding(8.dp) + .fillMaxWidth() + ) + + val buttonText = + if (isUpdateMode) stringResource(id = R.string.update) + else stringResource(id = R.string.create) + + FilledButton( + text = buttonText, + onClick = onCreateUpdateClicked, + enabled = errorText == null, + modifier = Modifier.padding(end = dimensionResource(id = R.dimen.card_margin_small)) + ) + } +} + diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/BottomButtons.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/BottomButtons.kt new file mode 100644 index 000000000..2ef5db44a --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/BottomButtons.kt @@ -0,0 +1,44 @@ +package com.samco.trackandgraph.ui.compose.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import com.samco.trackandgraph.R +import com.samco.trackandgraph.ui.compose.theming.tngColors + +@Composable +fun AddCancelBottomButtons( + modifier: Modifier = Modifier, + updateMode: Boolean, + onCancelClicked: () -> Unit, + onAddClicked: () -> Unit, + addButtonEnabled: Boolean = true +) { + val focusManager = LocalFocusManager.current + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + SmallTextButton( + stringRes = R.string.cancel, + onClick = onCancelClicked, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.tngColors.onSurface + ) + ) + val addButtonRes = if (updateMode) R.string.update else R.string.add + SmallTextButton( + stringRes = addButtonRes, + enabled = addButtonEnabled, + onClick = { + focusManager.clearFocus() + onAddClicked() + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/Buttons.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/Buttons.kt new file mode 100644 index 000000000..e2d67f17b --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/Buttons.kt @@ -0,0 +1,124 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ + +package com.samco.trackandgraph.ui.compose.ui + +import androidx.annotation.StringRes +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.samco.trackandgraph.R +import com.samco.trackandgraph.ui.compose.theming.tngColors + +@Composable +fun FilledButton( + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, + enabled: Boolean = true +) { + Button( + modifier = modifier, + onClick = onClick, + enabled = enabled, + shape = MaterialTheme.shapes.small, + ) { + Text(text = text) + } +} + +@Composable +fun WideButton( + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, + enabled: Boolean = true +) = FilledButton( + text = text, + modifier = modifier.fillMaxWidth(), + onClick = onClick, + enabled = enabled +) + + +@Composable +fun SmallTextButton( + @StringRes stringRes: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: ButtonColors = ButtonDefaults.textButtonColors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) = TextButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = MaterialTheme.shapes.small, + colors = colors, + interactionSource = interactionSource, +) { + Text(stringResource(stringRes)) +} + +@Composable +fun SelectorTextButton( + modifier: Modifier, + text: String, + enabled: Boolean = true, + onClick: () -> Unit +) = Button( + modifier = modifier, + onClick = onClick, + shape = MaterialTheme.shapes.small, + enabled = enabled, + contentPadding = PaddingValues( + horizontal = 8.dp, + vertical = 6.dp + ), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.tngColors.selectorButtonColor + ) +) { + Text( + text = text, + fontWeight = MaterialTheme.typography.subtitle2.fontWeight, + fontSize = MaterialTheme.typography.subtitle2.fontSize, + ) +} + +@Composable +fun AddBarButton( + modifier: Modifier = Modifier, + onClick: () -> Unit +) = Button( + modifier = modifier.widthIn(min = 160.dp), + onClick = onClick, + shape = MaterialTheme.shapes.small +) { + Icon( + painter = painterResource(id = R.drawable.add_icon), + contentDescription = stringResource(id = R.string.add) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/CheckboxLabeledExpandingSection.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/CheckboxLabeledExpandingSection.kt new file mode 100644 index 000000000..8a7a34238 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/CheckboxLabeledExpandingSection.kt @@ -0,0 +1,67 @@ +package com.samco.trackandgraph.ui.compose.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Checkbox +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.unit.dp +import com.samco.trackandgraph.R +import com.samco.trackandgraph.ui.compose.theming.disabledAlpha +import com.samco.trackandgraph.ui.compose.theming.tngColors + +@Composable +fun CheckboxLabeledExpandingSection( + checked: Boolean, + onCheckedChanged: (Boolean) -> Unit, + label: String, + focusRequester: FocusRequester, + input: @Composable (modifier: Modifier) -> Unit +) = Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .alpha(if (checked) 1.0f else MaterialTheme.tngColors.disabledAlpha) + .fillMaxWidth() + .border( + BorderStroke(1.dp, MaterialTheme.tngColors.onSurface), + shape = MaterialTheme.shapes.small + ) + .padding(dimensionResource(id = R.dimen.card_margin_small)) +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { onCheckedChanged(!checked) } + .fillMaxWidth() + ) { + Checkbox( + checked = checked, + onCheckedChange = { onCheckedChanged(it) } + ) + InputSpacingLarge() + Text(text = label) + } + if (checked) { + input( + Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/CustomDialogs.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/CustomDialogs.kt new file mode 100644 index 000000000..2a0419ee1 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/CustomDialogs.kt @@ -0,0 +1,202 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.ui.compose.ui + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.samco.trackandgraph.R +import com.samco.trackandgraph.ui.compose.theming.DialogTheme +import com.samco.trackandgraph.ui.compose.theming.tngColors + +@Composable +fun CustomDialog( + onDismissRequest: () -> Unit, + scrollContent: Boolean = true, + usePlatformDefaultWidth: Boolean = true, + paddingValues: PaddingValues = PaddingValues( + dimensionResource(id = R.dimen.card_padding_large) + ), + content: @Composable ColumnScope.() -> Unit, +) = DialogTheme { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties( + usePlatformDefaultWidth = usePlatformDefaultWidth, + ) + ) { + Surface { + Column( + modifier = Modifier + .padding(paddingValues) + .let { + if (scrollContent) it.verticalScroll(state = rememberScrollState()) + else it + }, + content = content + ) + } + } +} + +@Composable +fun CustomConfirmCancelDialog( + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, + customWidthPercentage: Float? = null, + @StringRes continueText: Int = R.string.continue_word, + @StringRes dismissText: Int = R.string.cancel, + continueEnabled: Boolean = true, + content: @Composable ColumnScope.() -> Unit +) = CustomDialog( + onDismissRequest = onDismissRequest, + scrollContent = false, + usePlatformDefaultWidth = customWidthPercentage == null, + paddingValues = PaddingValues( + start = dimensionResource(id = R.dimen.card_padding_large), + end = dimensionResource(id = R.dimen.card_padding_large), + bottom = dimensionResource(id = R.dimen.card_padding), + top = dimensionResource(id = R.dimen.card_padding_large), + ) +) { + Column( + if (customWidthPercentage != null) { + Modifier.fillMaxWidth(customWidthPercentage) + } else Modifier + .wrapContentHeight() + .verticalScroll(rememberScrollState()) + ) { + content() + + DialogInputSpacing() + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + SmallTextButton( + stringRes = dismissText, + onClick = onDismissRequest, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.tngColors.onSurface + ) + ) + SmallTextButton( + stringRes = continueText, + onClick = onConfirm, + enabled = continueEnabled + ) + } + } +} + +@Composable +fun ConfirmDialog( + onConfirm: () -> Unit, + dismissOnClickOutside: Boolean = true, + onDismissRequest: () -> Unit, + @StringRes continueText: Int = R.string.continue_word, + content: @Composable ColumnScope.() -> Unit +) = DialogTheme { + AlertDialog( + onDismissRequest = onDismissRequest, + shape = MaterialTheme.shapes.small, + text = { + Column( + modifier = Modifier + .verticalScroll(state = rememberScrollState()), + content = content + ) + }, + confirmButton = { + SmallTextButton( + stringRes = continueText, + onClick = onConfirm + ) + }, + properties = DialogProperties( + dismissOnClickOutside = dismissOnClickOutside + ), + ) +} + +@Composable +fun ConfirmCancelDialog( + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, + @StringRes continueText: Int = R.string.continue_word, + @StringRes dismissText: Int = R.string.cancel, + confirmButtonEnabled: Boolean = true, + content: @Composable ColumnScope.() -> Unit +) = DialogTheme { + AlertDialog( + onDismissRequest = onDismissRequest, + shape = MaterialTheme.shapes.small, + text = { + Column( + modifier = Modifier + .verticalScroll(state = rememberScrollState()), + content = content + ) + }, + confirmButton = { + SmallTextButton( + stringRes = continueText, + enabled = confirmButtonEnabled, + onClick = onConfirm + ) + }, + dismissButton = { + SmallTextButton( + stringRes = dismissText, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.tngColors.onSurface + ), + onClick = onDismissRequest + ) + } + ) +} + +@Composable +fun ConfirmCancelDialog( + @StringRes body: Int, + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, + @StringRes continueText: Int = R.string.continue_word, + @StringRes dismissText: Int = R.string.cancel +) = ConfirmCancelDialog( + onDismissRequest = onDismissRequest, + onConfirm = onConfirm, + continueText = continueText, + dismissText = dismissText, + content = { + Text( + text = stringResource(id = body), + style = MaterialTheme.typography.subtitle1, + color = MaterialTheme.tngColors.onSurface + ) + } +) diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/DateScrollLazyColumn.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/DateScrollLazyColumn.kt new file mode 100644 index 000000000..e203950a4 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/DateScrollLazyColumn.kt @@ -0,0 +1,345 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.ui.compose.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.scale +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.samco.trackandgraph.R +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import org.threeten.bp.OffsetDateTime +import org.threeten.bp.format.DateTimeFormatterBuilder +import org.threeten.bp.format.TextStyle +import org.threeten.bp.temporal.ChronoField +import kotlin.math.roundToInt +import kotlin.math.sin + +interface Datable { + val date: OffsetDateTime +} + +enum class DateDisplayResolution { + MONTH_DAY, + MONTH_YEAR, +} + +data class DateScrollData( + val dateDisplayResolution: DateDisplayResolution, + + val items: List +) + +private val monthDayFormatter = DateTimeFormatterBuilder() + .appendText(ChronoField.MONTH_OF_YEAR, TextStyle.SHORT) + .appendLiteral(' ') + .appendValue(ChronoField.DAY_OF_MONTH) + .toFormatter() + +private val monthYearFormatter = DateTimeFormatterBuilder() + .appendText(ChronoField.MONTH_OF_YEAR, TextStyle.SHORT) + .appendLiteral(' ') + .appendValue(ChronoField.YEAR) + .toFormatter() + +private val textOverlayAnimationSpec = tween(durationMillis = 300) +private val scrollBarAnimationSpec = tween(durationMillis = 300) + +@OptIn(FlowPreview::class) +@Composable +fun DateScrollLazyColumn( + modifier: Modifier = Modifier, + data: DateScrollData, + content: @Composable LazyItemScope.(item: T) -> Unit +) = Box(modifier = modifier) { + val scrollState = rememberLazyListState() + + LazyColumn(state = scrollState) { items(data.items) { content(it) } } + + val minItemsMet = remember(data.items) { data.items.size >= 50 } + val isDragging = remember { mutableStateOf(false) } + val scrollToIndex = remember { mutableStateOf(0) } + var isScrollBarVisible by remember { mutableStateOf(false) } + + LaunchedEffect(scrollState.isScrollInProgress, isDragging.value) { + if (scrollState.isScrollInProgress || isDragging.value) { + isScrollBarVisible = true + } else { + delay(2000) + isScrollBarVisible = false + } + } + + LaunchedEffect(scrollToIndex, isDragging) { + snapshotFlow { scrollToIndex.value } + .filter { isDragging.value } + .debounce(100) + .collect { scrollState.scrollToItem(it) } + } + + val currentDateText = remember(scrollToIndex.value, data) { + derivedStateOf { + if (data.items.isEmpty() || scrollToIndex.value !in data.items.indices) + return@derivedStateOf null + val date = data.items[scrollToIndex.value].date + when (data.dateDisplayResolution) { + DateDisplayResolution.MONTH_DAY -> date.format(monthDayFormatter) + DateDisplayResolution.MONTH_YEAR -> date.format(monthYearFormatter) + } + } + } + + val textOverlayVisible by remember(minItemsMet, isDragging, currentDateText) { + derivedStateOf { + minItemsMet && isDragging.value && currentDateText.value != null + } + } + + AnimatedVisibility( + visible = textOverlayVisible, + enter = fadeIn(animationSpec = textOverlayAnimationSpec), + exit = fadeOut(animationSpec = textOverlayAnimationSpec) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + colors = listOf( + MaterialTheme.colors.background.copy(alpha = 1f), + MaterialTheme.colors.background.copy(alpha = 0.4f) + ) + ) + ) + ) { + Text( + modifier = Modifier.align(Alignment.TopCenter), + text = currentDateText.value ?: "", + style = MaterialTheme.typography.h3, + color = MaterialTheme.colors.onBackground + ) + } + } + + AnimatedVisibility( + modifier = Modifier + .fillMaxHeight() + .align(Alignment.CenterEnd), + visible = minItemsMet && isScrollBarVisible, + enter = slideInHorizontally( + initialOffsetX = { it }, + animationSpec = scrollBarAnimationSpec + ), + exit = slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = scrollBarAnimationSpec + ) + ) { + ScrollBarCanvas( + data = data, + scrollState = scrollState, + isDragging = isDragging, + scrollToIndex = scrollToIndex + ) + } +} + +@Composable +private fun ScrollBarCanvas( + data: DateScrollData, + scrollState: LazyListState, + isDragging: MutableState, + scrollToIndex: MutableState +) { + // Scrollbar properties + var scrollbarOffsetY by remember { mutableStateOf(0f) } + val scrollGrabberColor = MaterialTheme.colors.primary + val scrollGrabberDiamDp = 50.dp + val scrollGrabberRadDp = remember(scrollGrabberDiamDp) { scrollGrabberDiamDp / 2 } + val scrollGrabberDiamPx = with(LocalDensity.current) { scrollGrabberDiamDp.toPx() } + val scrollGrabberRadPx = remember(scrollGrabberDiamPx) { scrollGrabberDiamPx / 2 } + var canvasHeight by remember { mutableStateOf(0) } + + //The extra sweep is the extra degrees on a semi circle that we're adding to both sides to make it + // feel like a circle with a bit of the side cut off. + val extraSweep = 35f + val startAngle = remember(extraSweep) { -90f + extraSweep } + val sweepAngle = remember(extraSweep) { -180f - (2 * extraSweep) } + val fullWidth = remember(scrollGrabberRadDp) { + scrollGrabberRadDp.value + (sin(Math.toRadians(extraSweep.toDouble())) * scrollGrabberRadDp.value) + } + + val icPainter = rememberVectorPainter(ImageVector.vectorResource(R.drawable.ic_scroll_grabber)) + val grabberIconColor = MaterialTheme.colors.onPrimary + val grabberIconColorFilter = remember { ColorFilter.tint(grabberIconColor) } + val grabberSize = remember { Size(scrollGrabberDiamPx, scrollGrabberDiamPx) } + + val coroutineScope = rememberCoroutineScope() + + Canvas(modifier = Modifier + .width(fullWidth.dp) + .fillMaxHeight() + .onGloballyPositioned { canvasHeight = it.size.height } + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { offset -> + isDragging.value = true + val columnHeight = size.height - scrollGrabberDiamPx + scrollbarOffsetY = (offset.y - scrollGrabberRadPx).coerceIn(0f, columnHeight) + val proportion = scrollbarOffsetY / columnHeight + val targetScrollOffset = (data.items.size * proportion).roundToInt() + scrollToIndex.value = targetScrollOffset + }, + onDrag = { change, dragAmount -> + change.consume() + val columnHeight = size.height - scrollGrabberDiamPx + scrollbarOffsetY = (scrollbarOffsetY + dragAmount.y).coerceIn(0f, columnHeight) + val proportion = scrollbarOffsetY / columnHeight + val targetScrollOffset = (data.items.size * proportion).roundToInt() + scrollToIndex.value = targetScrollOffset + }, + onDragEnd = { + //Fixes a small glitch where the scroll bar pings back to the old position + // before the scroll to item gets chance to run, as it is throttled + coroutineScope.launch { scrollState.scrollToItem(scrollToIndex.value) } + isDragging.value = false + }, + onDragCancel = { + //Fixes a small glitch where the scroll bar pings back to the old position + // before the scroll to item gets chance to run, as it is throttled + coroutineScope.launch { scrollState.scrollToItem(scrollToIndex.value) } + isDragging.value = false + } + ) + } + ) { + drawArc( + color = scrollGrabberColor, + topLeft = Offset(0f, scrollbarOffsetY), + startAngle = startAngle, + sweepAngle = sweepAngle, + useCenter = false, + size = grabberSize + ) + + drawIntoCanvas { + it.translate(0f, scrollbarOffsetY) + it.scale(0.75f, 0.75f, scrollGrabberRadPx, scrollGrabberRadPx) + icPainter.apply { + draw( + size = grabberSize, + colorFilter = grabberIconColorFilter + ) + } + } + } + + val firstVisibleItem by remember { + derivedStateOf { scrollState.firstVisibleItemIndex } + } + + val visibleItemCount by remember { + derivedStateOf { scrollState.layoutInfo.visibleItemsInfo.size } + } + + val visibleItemsSize by remember { + derivedStateOf { + scrollState.layoutInfo.visibleItemsInfo.sumOf { it.size } + + (scrollState.layoutInfo.mainAxisItemSpacing * (visibleItemCount - 1)) + } + } + + val scrollOffset by remember { + derivedStateOf { scrollState.firstVisibleItemScrollOffset } + } + + val viewportHeight by remember { + derivedStateOf { scrollState.layoutInfo.viewportSize.height } + } + + val offset by remember { + derivedStateOf { + val canvasScalar = canvasHeight.toFloat() - scrollGrabberDiamPx + + val totalItems = data.items.size + if (visibleItemsSize <= 0 || visibleItemCount <= 0) return@derivedStateOf 0f + + val averageItemSize = visibleItemsSize / visibleItemCount.toFloat() + + val estimatedAboveItemsSize = (firstVisibleItem * averageItemSize) + scrollOffset + val clippedAmountOfBottomItem = visibleItemsSize - scrollOffset - viewportHeight + val estimatedBelowItemsSize = + ((totalItems - firstVisibleItem - visibleItemCount) * averageItemSize) + clippedAmountOfBottomItem + val estimatedTotalHiddenContentSize = estimatedAboveItemsSize + estimatedBelowItemsSize + + val estimatedRatioInsideTotalContent = + estimatedAboveItemsSize / estimatedTotalHiddenContentSize + + canvasScalar * estimatedRatioInsideTotalContent + } + } + + LaunchedEffect(isDragging.value, offset) { + if (!isDragging.value) scrollbarOffsetY = offset + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/DateTimeSelectorButtons.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/DateTimeSelectorButtons.kt new file mode 100644 index 000000000..4fa8dcefd --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/DateTimeSelectorButtons.kt @@ -0,0 +1,198 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.ui.compose.ui + +import android.content.Context +import android.content.ContextWrapper +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import com.google.android.material.datepicker.CalendarConstraints +import com.google.android.material.datepicker.DateValidatorPointForward +import com.google.android.material.datepicker.MaterialDatePicker +import com.google.android.material.timepicker.MaterialTimePicker +import com.google.android.material.timepicker.MaterialTimePicker.INPUT_MODE_CLOCK +import com.google.android.material.timepicker.TimeFormat.CLOCK_24H +import com.samco.trackandgraph.base.helpers.formatDayMonthYear +import com.samco.trackandgraph.base.helpers.formatHourMinute +import com.samco.trackandgraph.ui.compose.compositionlocals.LocalSettings +import org.threeten.bp.DayOfWeek +import org.threeten.bp.Instant +import org.threeten.bp.OffsetDateTime +import org.threeten.bp.ZoneOffset +import java.util.Calendar +import kotlin.math.absoluteValue + +@Composable +fun DateTimeButtonRow( + modifier: Modifier = Modifier, + selectedDateTime: OffsetDateTime, + onDateTimeSelected: (OffsetDateTime) -> Unit, +) = Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceEvenly +) { + + DateButton( + modifier = Modifier.widthIn(min = 104.dp), + dateTime = selectedDateTime, + onDateSelected = { odt -> + onDateTimeSelected( + odt + .withHour(selectedDateTime.hour) + .withMinute(selectedDateTime.minute) + .withSecond(selectedDateTime.second) + .withNano(selectedDateTime.nano) + ) + } + ) + TimeButton( + modifier = Modifier.widthIn(min = 104.dp), + dateTime = selectedDateTime, + onTimeSelected = { time -> + onDateTimeSelected( + selectedDateTime + .withHour(time.hour) + .withMinute(time.minute) + ) + } + ) +} + +data class SelectedTime( + val hour: Int, + val minute: Int +) + +@Composable +fun TimeButton( + modifier: Modifier = Modifier, + dateTime: OffsetDateTime, + enabled: Boolean = true, + onTimeSelected: (SelectedTime) -> Unit +) = Box { + + val tag = "TimePicker" + val context = LocalContext.current + + SelectorTextButton( + modifier = modifier, + text = formatHourMinute(dateTime), + enabled = enabled, + onClick = { + val fragmentManager = findFragmentManager(context) ?: return@SelectorTextButton + val fragment = fragmentManager.findFragmentByTag(tag) + val existingPicker = fragment as? MaterialTimePicker + val picker = existingPicker ?: MaterialTimePicker.Builder() + .setHour(dateTime.hour) + .setMinute(dateTime.minute) + .setTimeFormat(CLOCK_24H) + .setInputMode(INPUT_MODE_CLOCK) + .build() + picker.apply { + addOnPositiveButtonClickListener { + onTimeSelected(SelectedTime(this.hour, this.minute)) + } + show(fragmentManager, tag) + } + } + ) +} + +@Composable +fun DateButton( + modifier: Modifier = Modifier, + dateTime: OffsetDateTime, + enabled: Boolean = true, + allowPastDates: Boolean = true, + onDateSelected: (OffsetDateTime) -> Unit, +) = Box { + val context = LocalContext.current + val firstDayOfWeek = LocalSettings.current.firstDayOfWeek + SelectorTextButton( + modifier = modifier, + text = formatDayMonthYear(context, dateTime), + enabled = enabled, + onClick = { + showDateDialog(context, onDateSelected, firstDayOfWeek, allowPastDates, dateTime) + } + ) +} + +fun showDateDialog( + context: Context, + onDateSelected: (OffsetDateTime) -> Unit, + firstDayOfWeek: DayOfWeek, + allowPastDates: Boolean = true, + dateTime: OffsetDateTime = OffsetDateTime.now() +) { + val tag = "DatePicker" + val fragmentManager = findFragmentManager(context) ?: return + val fragment = fragmentManager.findFragmentByTag(tag) + val existingPicker = fragment as? MaterialDatePicker<*> + val dowMap = mapOf( + DayOfWeek.MONDAY to Calendar.MONDAY, + DayOfWeek.TUESDAY to Calendar.TUESDAY, + DayOfWeek.WEDNESDAY to Calendar.WEDNESDAY, + DayOfWeek.THURSDAY to Calendar.THURSDAY, + DayOfWeek.FRIDAY to Calendar.FRIDAY, + DayOfWeek.SATURDAY to Calendar.SATURDAY, + DayOfWeek.SUNDAY to Calendar.SUNDAY + ) + val picker = existingPicker ?: MaterialDatePicker.Builder + .datePicker() + .setSelection(dateTime.toInstant().toEpochMilli()) + .setCalendarConstraints( + CalendarConstraints.Builder() + .let { + if (allowPastDates) it + else it.setValidator(DateValidatorPointForward.now()) + } + .setFirstDayOfWeek(dowMap[firstDayOfWeek] ?: Calendar.MONDAY) + .build() + ) + .build() + + picker.apply { + addOnPositiveButtonClickListener { obj -> + val epochMillis = (obj as? Long) ?: return@addOnPositiveButtonClickListener + //epochMillis is 00:00 of a date in UTC, we want to return the same date in the local timezone + val utcDate = Instant.ofEpochMilli(epochMillis).atOffset(ZoneOffset.UTC) + val selected = utcDate.withOffsetSameLocal(OffsetDateTime.now().offset) + onDateSelected(selected) + } + show(fragmentManager, tag) + } +} + + +private fun findFragmentManager(context: Context): FragmentManager? { + var currentContext = context + while (currentContext !is FragmentActivity) { + if (currentContext is ContextWrapper) { + currentContext = currentContext.baseContext + } else break + } + return (currentContext as? FragmentActivity)?.supportFragmentManager +} diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/DurationInput.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/DurationInput.kt new file mode 100644 index 000000000..db60292e5 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/DurationInput.kt @@ -0,0 +1,125 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.ui.compose.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.* +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.samco.trackandgraph.R +import com.samco.trackandgraph.ui.viewmodels.DurationInputViewModel +import com.samco.trackandgraph.ui.viewmodels.DurationInputViewModelImpl + +@Preview(showBackground = true, device = Devices.PIXEL_3) +@Composable +private fun DurationInputPreview() = DurationInput( + modifier = Modifier, + viewModel = DurationInputViewModelImpl() +) + +@Composable +fun DurationInput( + modifier: Modifier = Modifier, + viewModel: DurationInputViewModel, + focusManager: FocusManager = LocalFocusManager.current, + nextFocusDirection: FocusDirection? = null, + focusRequester: FocusRequester? = null +) = Row( + modifier = modifier + .padding( + horizontal = dimensionResource(id = R.dimen.card_padding), + vertical = dimensionResource(id = R.dimen.card_padding) + ), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.Center +) { + DurationInputComponent( + textFieldValue = viewModel.hours, + onValueChange = { viewModel.setHoursText(it) }, + suffix = stringResource(id = R.string.hours_suffix), + charLimit = 8, + focusManager = focusManager, + focusRequester = focusRequester + ) + Text( + text = ":", + textAlign = TextAlign.Center, + modifier = Modifier.alignByBaseline() + ) + DurationInputComponent( + textFieldValue = viewModel.minutes, + onValueChange = { viewModel.setMinutesText(it) }, + suffix = stringResource(id = R.string.minutes_suffix), + charLimit = 3, + focusManager = focusManager + ) + Text( + text = ":", + textAlign = TextAlign.Center, + modifier = Modifier.alignByBaseline() + ) + DurationInputComponent( + textFieldValue = viewModel.seconds, + onValueChange = { viewModel.setSecondsText(it) }, + suffix = stringResource(id = R.string.seconds_suffix), + charLimit = 3, + focusManager = focusManager, + overrideFocusDirection = nextFocusDirection + ) +} + + +@Composable +private fun RowScope.DurationInputComponent( + textFieldValue: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + suffix: String, + charLimit: Int, + focusManager: FocusManager, + overrideFocusDirection: FocusDirection? = null, + focusRequester: FocusRequester? = null +) { + MiniNumericTextField( + modifier = Modifier.alignByBaseline(), + textFieldValue = textFieldValue, + onValueChange = onValueChange, + charLimit = charLimit, + focusManager = focusManager, + overrideFocusDirection = overrideFocusDirection, + focusRequester = focusRequester + ) + + //Align baseline to above text field + Text( + text = suffix, + modifier = Modifier + .padding(horizontal = 4.dp) + .alignByBaseline() + ) +} + diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/EmptyScreenText.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/EmptyScreenText.kt new file mode 100644 index 000000000..e42646567 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/EmptyScreenText.kt @@ -0,0 +1,50 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.ui.compose.ui + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun EmptyScreenText( + @StringRes textId: Int +) = Box { + Text( + modifier = Modifier + .width(250.dp) + .wrapContentHeight() + .align(Alignment.Center), + text = stringResource(id = textId), + fontSize = MaterialTheme.typography.subtitle2.fontSize, + fontWeight = MaterialTheme.typography.subtitle2.fontWeight, + textAlign = TextAlign.Center, + color = MaterialTheme.colors.onSurface.copy( + alpha = 0.6f + ) + ) +} + diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/FadingLazyRow.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/FadingLazyRow.kt new file mode 100644 index 000000000..77316d9d6 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/FadingLazyRow.kt @@ -0,0 +1,76 @@ +package com.samco.trackandgraph.ui.compose.ui + +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.samco.trackandgraph.ui.compose.theming.tngColors + +@Composable +fun FadingLazyRow( + modifier: Modifier = Modifier, + fadeSize: Float = with(LocalDensity.current) { 24.dp.toPx() }, + fadeColor: Color = MaterialTheme.tngColors.surface, + contentPadding: PaddingValues = PaddingValues(horizontal = 24.dp), + state: LazyListState = rememberLazyListState(), + reverseLayout: Boolean = false, + horizontalArrangement: Arrangement.Horizontal = + if (!reverseLayout) Arrangement.Start else Arrangement.End, + verticalAlignment: Alignment.Vertical = Alignment.Top, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled: Boolean = true, + content: LazyListScope.() -> Unit +) { + val endColors = listOf(Color.Transparent, fadeColor) + val startColors = listOf(fadeColor, Color.Transparent) + + Box(modifier = Modifier.fillMaxSize()) { + LazyRow( + modifier = modifier + .fillMaxSize() + .graphicsLayer { alpha = 0.99f } + .drawWithContent { + drawContent() + drawRect( + Brush.horizontalGradient(startColors, 0f, fadeSize), + size = Size(fadeSize, this.size.height), + blendMode = BlendMode.Companion.SrcOver + ) + drawRect( + brush = Brush.horizontalGradient( + endColors, + this.size.width - fadeSize, + this.size.width + ), + topLeft = Offset(this.size.width - fadeSize, 0f), + size = Size(fadeSize, this.size.height), + blendMode = BlendMode.Companion.SrcOver + ) + }, + state = state, + contentPadding = contentPadding, + reverseLayout = reverseLayout, + horizontalArrangement = horizontalArrangement, + verticalAlignment = verticalAlignment, + flingBehavior = flingBehavior, + userScrollEnabled = userScrollEnabled, + content = content + ) + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/FadingScrollColumn.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/FadingScrollColumn.kt new file mode 100644 index 000000000..1f99332f9 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/FadingScrollColumn.kt @@ -0,0 +1,75 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.ui.compose.ui + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.samco.trackandgraph.ui.compose.theming.tngColors + +@Composable +fun FadingScrollColumn( + modifier: Modifier = Modifier, + fadeSize: Dp = 32.dp, + color: Color = MaterialTheme.tngColors.surface, + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + scrollState: ScrollState = rememberScrollState(), + content: @Composable ColumnScope.() -> Unit +) { + val bottomColors = listOf(Color.Transparent, color) + val topColors = listOf(color, Color.Transparent) + + Box { + Column( + modifier = modifier.verticalScroll(scrollState), + verticalArrangement = verticalArrangement, + horizontalAlignment = horizontalAlignment, + content = content + ) + + if (scrollState.value > 1) { + Spacer( + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(fadeSize) + .background(brush = Brush.verticalGradient(topColors)) + ) + } + + if (scrollState.value < scrollState.maxValue - 1) { + Spacer( + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(fadeSize) + .background(brush = Brush.verticalGradient(bottomColors)) + ) + } + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/FullWidthTextField.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/FullWidthTextField.kt new file mode 100644 index 000000000..b5e8cf2a8 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/FullWidthTextField.kt @@ -0,0 +1,77 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.ui.compose.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.* +import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.TextFieldValue + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun FullWidthTextField( + modifier: Modifier = Modifier, + textFieldValue: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + label: String = "", + focusManager: FocusManager? = null, + focusRequester: FocusRequester? = null, + keyboardController: SoftwareKeyboardController? = null, + singleLine: Boolean = true +) { + val keyboardActions = + if (focusManager != null) KeyboardActions(onNext = { + focusManager.moveFocus(FocusDirection.Down) + }) else KeyboardActions.Default + + val keyboardOptions = + if (singleLine) KeyboardOptions( + imeAction = ImeAction.Next, + capitalization = KeyboardCapitalization.Sentences + ) else KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences + ) + + SlimOutlinedTextField( + value = textFieldValue, + label = { Text(text = label) }, + onValueChange = { onValueChange(it) }, + keyboardActions = keyboardActions, + keyboardOptions = keyboardOptions, + singleLine = singleLine, + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .onFocusChanged { + if (it.hasFocus) keyboardController?.show() + } + .let { + if (focusRequester != null) it.focusRequester(focusRequester) + else it + } + ) +} + diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/InfoDialogs.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/InfoDialogs.kt new file mode 100644 index 000000000..0aada9d6c --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/InfoDialogs.kt @@ -0,0 +1,96 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.ui.compose.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import com.samco.trackandgraph.base.database.dto.DataPoint +import com.samco.trackandgraph.base.database.dto.Feature +import com.samco.trackandgraph.base.helpers.formatDayMonthYearHourMinuteWeekDayOneLine +import com.samco.trackandgraph.base.helpers.getDisplayValue + + +@Composable +fun DataPointInfoDialog( + dataPoint: DataPoint, + isDuration: Boolean, + weekdayNames: List, + onDismissRequest: () -> Unit +) = CustomDialog(onDismissRequest) { + Text( + formatDayMonthYearHourMinuteWeekDayOneLine( + LocalContext.current, + weekdayNames, + dataPoint.timestamp + ), + fontSize = MaterialTheme.typography.h5.fontSize, + fontWeight = MaterialTheme.typography.h5.fontWeight + ) + DialogInputSpacing() + Text(dataPoint.getDisplayValue(isDuration)) + Text(dataPoint.note) +} + +@Composable +fun DataPointValueAndDescription( + modifier: Modifier = Modifier, + dataPoint: DataPoint, + isDuration: Boolean, + restrictNoteText: Boolean = true +) = Column(modifier = modifier) { + Text( + text = dataPoint.getDisplayValue(isDuration), + fontSize = MaterialTheme.typography.subtitle2.fontSize, + fontWeight = MaterialTheme.typography.subtitle2.fontWeight, + ) + if (dataPoint.note.isNotEmpty()) { + DialogInputSpacing() + if (restrictNoteText) { + Text( + text = dataPoint.note, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.body2 + ) + } else { + Text( + text = dataPoint.note, + style = MaterialTheme.typography.body2 + ) + } + } +} + +@Composable +fun FeatureInfoDialog( + feature: Feature, + onDismissRequest: () -> Unit +) = CustomDialog(onDismissRequest) { + Text( + feature.name, + fontSize = MaterialTheme.typography.h5.fontSize, + fontWeight = MaterialTheme.typography.h5.fontWeight + ) + DialogInputSpacing() + Text(feature.description) +} + diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/LabeledRow.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/LabeledRow.kt new file mode 100644 index 000000000..6d27a1982 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/LabeledRow.kt @@ -0,0 +1,48 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.ui.compose.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import com.samco.trackandgraph.R + +@Composable +fun LabeledRow( + modifier: Modifier = Modifier, + label: String, + paddingValues: PaddingValues = PaddingValues(horizontal = dimensionResource(id = R.dimen.card_padding)), + input: @Composable RowScope.() -> Unit +) = Row( + modifier = modifier.padding(paddingValues), + verticalAlignment = Alignment.CenterVertically +) { + Text( + text = label, + style = MaterialTheme.typography.subtitle2 + ) + InputSpacingLarge() + input() +} + diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/LoadingOverlay.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/LoadingOverlay.kt new file mode 100644 index 000000000..cbb95a697 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/LoadingOverlay.kt @@ -0,0 +1,56 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.ui.compose.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.samco.trackandgraph.ui.compose.theming.disabledAlpha +import com.samco.trackandgraph.ui.compose.theming.tngColors + +@Composable +fun LoadingOverlay() = Box( + contentAlignment = Alignment.Center +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { } + .focusable(), + color = MaterialTheme.tngColors.surface.copy( + alpha = MaterialTheme.tngColors.disabledAlpha + ) + ) { + Box(contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/MiniNumericTextField.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/MiniNumericTextField.kt new file mode 100644 index 000000000..edc6f9a9b --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/MiniNumericTextField.kt @@ -0,0 +1,113 @@ +@file:OptIn(ExperimentalMaterialApi::class, ExperimentalMaterialApi::class) + +package com.samco.trackandgraph.ui.compose.ui + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.* +import androidx.compose.material.TextFieldDefaults.indicatorLine +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.* +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.samco.trackandgraph.ui.compose.theming.disabledAlpha +import com.samco.trackandgraph.ui.compose.theming.tngColors +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun MiniNumericTextField( + modifier: Modifier = Modifier, + textFieldValue: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + charLimit: Int? = null, + textAlign: TextAlign = TextAlign.End, + focusManager: FocusManager = LocalFocusManager.current, + overrideFocusDirection: FocusDirection? = null, + focusRequester: FocusRequester? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + focusUpdateScope: CoroutineScope = rememberCoroutineScope(), + colors: TextFieldColors = TextFieldDefaults.textFieldColors(), + enabled: Boolean = true +) { + BasicTextField( + value = textFieldValue, + onValueChange = { + if (charLimit == null || it.text.length <= charLimit) onValueChange(it) + }, + textStyle = MaterialTheme.typography.h5.copy( + textAlign = textAlign, + color = MaterialTheme.tngColors.onSurface + ), + cursorBrush = SolidColor(MaterialTheme.tngColors.primary), + enabled = enabled, + interactionSource = interactionSource, + //Due to a bug tracked here: https://issuetracker.google.com/issues/215116019 + //we currently can't use decorationBox to display a 0 when the field is empty. + //There's some sort of race condition that crashes the app when the focus changes + //and the text is added/removed. +/* + decorationBox = { + if (textFieldValue.text == "" && !isFocused) { + Text( + "0", + style = MaterialTheme.typography.h5, + textAlign = textAlign, + color = MaterialTheme.tngColors.onSurface.copy( + alpha = MaterialTheme.tngColors.disabledAlpha + ) + ) + } else it() + }, +*/ + keyboardActions = KeyboardActions( + onNext = { + focusManager.moveFocus( + overrideFocusDirection ?: FocusDirection.Right + ) + } + ), + singleLine = true, + modifier = modifier + .width(IntrinsicSize.Min) + .padding(start = 4.dp) + .widthIn(min = 40.dp, max = 40.dp) + .indicatorLine( + enabled = true, + isError = false, + interactionSource = interactionSource, + colors = colors + ) + .onFocusChanged { focusState -> + if (focusState.isFocused) { + focusUpdateScope.launch { + delay(20) + onValueChange( + textFieldValue.copy( + selection = TextRange(0, textFieldValue.text.length) + ) + ) + } + } + } + .let { + if (focusRequester != null) it.focusRequester(focusRequester) + else it + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/RowCheckbox.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/RowCheckbox.kt new file mode 100644 index 000000000..ece73efda --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/RowCheckbox.kt @@ -0,0 +1,45 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.ui.compose.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Checkbox +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun RowCheckbox( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + text: String +) = Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { onCheckedChange?.invoke(!checked) } + .padding(end = 14.dp) +) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + ) + Text(text = text) +} diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/Shape.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/Shape.kt new file mode 100644 index 000000000..5fd3b7b3c --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/Shape.kt @@ -0,0 +1,25 @@ +package com.samco.trackandgraph.ui.compose.ui + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Shapes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.samco.trackandgraph.R + +val cardCornerRadius: Dp + @Composable + get() = dimensionResource(id = R.dimen.card_corner_radius) + +val shapeMedium = 8.dp + +val shapeLarge = 16.dp + +val shapes: Shapes + @Composable + get() = Shapes( + small = RoundedCornerShape(cardCornerRadius), + medium = RoundedCornerShape(shapeMedium), + large = RoundedCornerShape(shapeLarge) + ) diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/SlimOutlinedTextField.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/SlimOutlinedTextField.kt new file mode 100644 index 000000000..1eeb5b8ad --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/SlimOutlinedTextField.kt @@ -0,0 +1,118 @@ +package com.samco.trackandgraph.ui.compose.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.TextFieldColors +import androidx.compose.material.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp + +private val OutlinedTextFieldTopPadding = 8.dp +private val OutlinedTextFieldContentPadding = 12.dp + +//Annoyingly you can't change the padding on the material library OutlinedTextField so this +// is a copy/paste with smaller padding values. If you upgrade compose and this breaks look +// at OutlinedTextField +@Composable +fun SlimOutlinedTextField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions(), + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = TextFieldDefaults.OutlinedTextFieldShape, + colors: TextFieldColors = TextFieldDefaults.outlinedTextFieldColors() +) { + // If color is not provided via the text style, use content color as a default + val textColor = textStyle.color.takeOrElse { + colors.textColor(enabled).value + } + val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) + + @OptIn(ExperimentalMaterialApi::class) + BasicTextField( + value = value, + modifier = if (label != null) { + modifier + // Merge semantics at the beginning of the modifier chain to ensure padding is + // considered part of the text field. + .semantics(mergeDescendants = true) {} + .padding(top = OutlinedTextFieldTopPadding) + } else { + modifier + } + .background(colors.backgroundColor(enabled).value, shape) + .defaultMinSize( + minWidth = TextFieldDefaults.MinWidth, + minHeight = 20.dp + ), + onValueChange = onValueChange, + enabled = enabled, + readOnly = readOnly, + textStyle = mergedTextStyle, + cursorBrush = SolidColor(colors.cursorColor(isError).value), + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + interactionSource = interactionSource, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + decorationBox = @Composable { innerTextField -> + TextFieldDefaults.OutlinedTextFieldDecorationBox( + value = value.text, + visualTransformation = visualTransformation, + innerTextField = innerTextField, + placeholder = placeholder, + label = label, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + singleLine = singleLine, + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + contentPadding = PaddingValues(OutlinedTextFieldContentPadding), + colors = colors, + border = { + TextFieldDefaults.BorderBox( + enabled, + isError, + interactionSource, + colors, + shape + ) + } + ) + } + ) +} diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/Spacing.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/Spacing.kt new file mode 100644 index 000000000..3d4903eb0 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/Spacing.kt @@ -0,0 +1,67 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.ui.compose.ui + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.samco.trackandgraph.R + +val cardMarginSmall: Dp + @Composable + get() = dimensionResource(id = R.dimen.card_margin_small) + +val halfDialogInputSpacing: Dp + @Composable + get() = dimensionResource(id = R.dimen.half_dialog_input_spacing) + +val cardPadding: Dp + @Composable + get() = dimensionResource(id = R.dimen.card_padding) + +val dialogInputSpacing: Dp + @Composable + get() = dimensionResource(id = R.dimen.dialog_input_spacing) + +val inputSpacingLarge: Dp + @Composable + get() = dimensionResource(id = R.dimen.input_spacing_large) + +val inputSpacingXLarge = 50.dp + + +@Composable +fun CardMarginSmall() = Spacer(modifier = Modifier.size(cardMarginSmall)) + +@Composable +fun HalfDialogInputSpacing() = Spacer(modifier = Modifier.size(halfDialogInputSpacing)) + +@Composable +fun CardPadding() = Spacer(modifier = Modifier.size(cardPadding)) + +@Composable +fun DialogInputSpacing() = Spacer(modifier = Modifier.size(dialogInputSpacing)) + +@Composable +fun InputSpacingLarge() = Spacer(modifier = Modifier.size(inputSpacingLarge)) + +@Composable +fun InputSpacingXLarge() = Spacer(modifier = Modifier.size(inputSpacingXLarge)) diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/Spinners.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/Spinners.kt new file mode 100644 index 000000000..ba77f85fd --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/Spinners.kt @@ -0,0 +1,160 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +@file:OptIn(ExperimentalMaterialApi::class) + +package com.samco.trackandgraph.ui.compose.ui + +import androidx.annotation.ColorRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.samco.trackandgraph.R +import com.samco.trackandgraph.ui.dataVisColorList + +@Composable +fun Spinner( + modifier: Modifier = Modifier, + dropDownModifier: Modifier = Modifier, + items: List, + selectedItem: T, + onItemSelected: (T) -> Unit, + selectedItemFactory: @Composable RowScope.(Modifier, T, Boolean) -> Unit, + dropdownItemFactory: @Composable (T, Int) -> Unit, + enabled: Boolean = true, + enableTrailingIcon: Boolean = true, + dropdownContentAlignment: Alignment.Horizontal = Alignment.Start +) { + var expanded: Boolean by remember { mutableStateOf(false) } + + Box(modifier = modifier.wrapContentSize(Alignment.TopStart)) { + Row( + modifier = modifier + .padding(horizontal = dimensionResource(id = R.dimen.card_padding)) + .let { + if(enabled) it.clickable { expanded = !expanded } + else it + }, + verticalAlignment = Alignment.CenterVertically + ) { + selectedItemFactory(Modifier, selectedItem, expanded) + if (enableTrailingIcon) + ExposedDropdownMenuDefaults.TrailingIcon(expanded) { + if (enabled) expanded = !expanded + } + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = dropDownModifier + .background(color = MaterialTheme.colors.surface) + ) { + items.forEachIndexed { index, element -> + DropdownMenuItem( + enabled = enabled, + onClick = { + onItemSelected(items[index]) + expanded = false + } + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = dropdownContentAlignment + ) { + dropdownItemFactory(element, index) + } + } + } + } + } +} + +@Composable +fun ColorSpinner( + modifier: Modifier = Modifier, + colors: List = dataVisColorList, + selectedColor: Int, + onColorSelected: (Int) -> Unit +) = CircleSpinner( + modifier = modifier, + numItems = colors.size, + selectedIndex = selectedColor, + onIndexSelected = { onColorSelected(it) }, + circleContent = { index -> ColorCircle(color = colors[index]) } +) + +@Composable +fun CircleSpinner( + modifier: Modifier = Modifier, + numItems: Int, + selectedIndex: Int, + onIndexSelected: (Int) -> Unit, + circleContent: @Composable (Int) -> Unit, +) = Spinner( + modifier = modifier, + items = (0 until numItems).toList(), + selectedItem = selectedIndex, + onItemSelected = { onIndexSelected(it) }, + selectedItemFactory = { mod, index, _ -> + Circle( + modifier = mod, + content = { circleContent(index) } + ) + }, + dropdownItemFactory = { index, _ -> + HalfDialogInputSpacing() + Circle(content = { circleContent(index) }) + HalfDialogInputSpacing() + }, + enableTrailingIcon = false, + dropdownContentAlignment = Alignment.CenterHorizontally +) + +@Composable +fun ColorCircle( + modifier: Modifier = Modifier, + size: Dp = 54.dp, + @ColorRes color: Int +) = Circle( + modifier = modifier, + size = size, + backgroundColor = colorResource(id = color) +) {} + +@Composable +fun Circle( + modifier: Modifier = Modifier, + size: Dp = 54.dp, + backgroundColor: Color = MaterialTheme.colors.surface, + content: @Composable () -> Unit +) = Card( + modifier = modifier.size(size), + shape = RoundedCornerShape(100), + backgroundColor = backgroundColor, + elevation = 0.dp, + content = content +) \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/Text.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/Text.kt new file mode 100644 index 000000000..cf610f2d2 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/Text.kt @@ -0,0 +1,99 @@ +package com.samco.trackandgraph.ui.compose.ui/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.samco.trackandgraph.ui.compose.theming.TnGComposeTheme +import com.samco.trackandgraph.ui.compose.theming.tngColors + +@Preview +@Composable +private fun TrackerNameHeadlinePreview() = TnGComposeTheme { + TrackerNameHeadline(name = "Tracker name") +} + +@Composable +fun TrackerNameHeadline( + name: String +) = Column(Modifier.width(IntrinsicSize.Max)) { + Text( + modifier = Modifier.wrapContentWidth(), + text = name, + textAlign = TextAlign.Center, + fontSize = MaterialTheme.typography.h4.fontSize, + fontWeight = MaterialTheme.typography.h4.fontWeight + ) + Box( + Modifier + .background(MaterialTheme.colors.secondary) + .fillMaxWidth() + .height(1.dp) + ) +} + +@Composable +fun TextBody1( + modifier: Modifier = Modifier, + text: String, + textAlign: TextAlign? = null, + maxLines: Int = Int.MAX_VALUE +) = Text( + modifier = modifier, + text = text, + color = MaterialTheme.tngColors.textColorSecondary, + textAlign = textAlign, + style = MaterialTheme.typography.body1, + maxLines = maxLines +) + +@Composable +fun TextSubtitle2( + modifier: Modifier = Modifier, + text: String, + textAlign: TextAlign? = null, + maxLines: Int = Int.MAX_VALUE +) = Text( + modifier = modifier, + text = text, + textAlign = textAlign, + style = MaterialTheme.typography.subtitle2, + color = MaterialTheme.colors.onSurface, + maxLines = maxLines +) + +@Composable +fun TextLink( + modifier: Modifier = Modifier, + text: String, + textAlign: TextAlign? = null, + maxLines: Int = Int.MAX_VALUE, + onClick: () -> Unit +) = Text( + modifier = modifier.clickable(onClick = onClick), + text = text, + textAlign = textAlign, + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.secondaryVariant, + maxLines = maxLines +) \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/TextChip.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/TextChip.kt new file mode 100644 index 000000000..5d8020faa --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/TextChip.kt @@ -0,0 +1,111 @@ +@file:OptIn(ExperimentalFoundationApi::class) + +package com.samco.trackandgraph.ui.compose.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.samco.trackandgraph.R +import com.samco.trackandgraph.ui.compose.theming.tngColors + +@Preview +@Composable +fun TextChip( + modifier: Modifier = Modifier, + text: String = "Chip", + isSelected: Boolean = false, + onClick: () -> Unit = {}, + onLongPress: () -> Unit = {} +) = TngChip( + modifier = modifier, + onClick = onClick, + onLongPress = onLongPress, + isSelected = isSelected +) { + Text( + text = text, + style = MaterialTheme.typography.subtitle2 + ) +} + +@Composable +fun AddChipButton( + text: String, + onClick: () -> Unit +) = TngChip( + onClick = onClick, + shape = RoundedCornerShape(100.dp), + contentPaddingValues = PaddingValues(dimensionResource(id = R.dimen.card_margin_small)) +) { + Icon( + painter = painterResource(id = R.drawable.add_icon), + contentDescription = stringResource(id = R.string.add_a_note), + tint = MaterialTheme.tngColors.onSurface + ) + Text( + text = text, + style = MaterialTheme.typography.body1, + color = MaterialTheme.tngColors.onSurface + ) + DialogInputSpacing() +} + +@Composable +fun TngChip( + modifier: Modifier = Modifier, + isSelected: Boolean = false, + onClick: () -> Unit = {}, + onLongPress: () -> Unit = {}, + shape: Shape = MaterialTheme.shapes.medium, + contentPaddingValues: PaddingValues = PaddingValues( + horizontal = dimensionResource(id = R.dimen.card_padding), + vertical = 8.dp + ), + content: @Composable RowScope.() -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val buttonDown by interactionSource.collectIsPressedAsState() + + Surface( + modifier = modifier + .clip(shape) + .combinedClickable( + indication = LocalIndication.current, + onClick = onClick, + onLongClick = onLongPress, + interactionSource = interactionSource + ) + .widthIn(min = 60.dp), + border = BorderStroke( + 1.4.dp, + if (isSelected || buttonDown) SolidColor(MaterialTheme.tngColors.primary) + else SolidColor(MaterialTheme.tngColors.secondary) + ), + shape = shape, + ) { + Row( + Modifier.padding(contentPaddingValues), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + content() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/TextMapSpinner.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/TextMapSpinner.kt new file mode 100644 index 000000000..6d1e136d5 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/TextMapSpinner.kt @@ -0,0 +1,56 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.ui.compose.ui + +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun TextMapSpinner( + modifier: Modifier = Modifier, + strings: Map, + selectedItem: T, + enabled: Boolean = true, + onItemSelected: (T) -> Unit +) { + Spinner( + modifier = modifier, + items = strings.keys.toList(), + selectedItem = selectedItem, + onItemSelected = onItemSelected, + enabled = enabled, + selectedItemFactory = { modifier, item, expanded -> + Text( + modifier = modifier.weight(1f), + text = strings[item] ?: "", + fontSize = MaterialTheme.typography.body1.fontSize, + fontWeight = MaterialTheme.typography.body1.fontWeight, + ) + }, + dropdownItemFactory = { item, _ -> + Text( + text = strings[item] ?: "", + fontSize = MaterialTheme.typography.body1.fontSize, + fontWeight = MaterialTheme.typography.body1.fontWeight + ) + } + ) +} + diff --git a/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/ValueInputTextField.kt b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/ValueInputTextField.kt new file mode 100644 index 000000000..d4eb184af --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/compose/ui/ValueInputTextField.kt @@ -0,0 +1,127 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.ui.compose.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.* +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import com.samco.trackandgraph.R +import com.samco.trackandgraph.ui.compose.theming.disabledAlpha +import com.samco.trackandgraph.ui.compose.theming.tngColors +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + + +@Composable +fun LabelInputTextField( + modifier: Modifier = Modifier, + textFieldValue: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + focusManager: FocusManager? = null, + focusRequester: FocusRequester? = null, + onNextOverride: (() -> Unit)? = null +) { + SlimOutlinedTextField( + value = textFieldValue, + onValueChange = { onValueChange(it) }, + label = { Text(stringResource(id = R.string.label)) }, + keyboardActions = KeyboardActions( + onNext = { + if (onNextOverride != null) onNextOverride() + else focusManager?.moveFocus(FocusDirection.Down) + } + ), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + capitalization = KeyboardCapitalization.Sentences + ), + singleLine = true, + modifier = modifier.let { + if (focusRequester != null) it.focusRequester(focusRequester) + else it + } + ) +} + +@Composable +fun ValueInputTextField( + modifier: Modifier = Modifier, + textFieldValue: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + focusManager: FocusManager? = null, + focusRequester: FocusRequester? = null, + onNextOverride: (() -> Unit)? = null +) { + val focusUpdateScope = rememberCoroutineScope() + + SlimOutlinedTextField( + value = textFieldValue, + onValueChange = { onValueChange(it) }, + label = { Text(stringResource(id = R.string.value)) }, + keyboardActions = KeyboardActions( + onNext = { + if (onNextOverride != null) onNextOverride() + else focusManager?.moveFocus(FocusDirection.Down) + } + ), + textStyle = MaterialTheme.typography.subtitle2, + placeholder = { + Text( + modifier = Modifier.fillMaxWidth(), + text = "1.0", + style = MaterialTheme.typography.subtitle2, + color = MaterialTheme.tngColors.onSurface.copy( + alpha = MaterialTheme.tngColors.disabledAlpha + ) + ) + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Next + ), + singleLine = true, + modifier = modifier + .onFocusChanged { focusState -> + if (focusState.isFocused) { + focusUpdateScope.launch { + delay(20) + onValueChange( + textFieldValue.copy( + selection = TextRange(0, textFieldValue.text.length) + ) + ) + } + } + } + .let { + if (focusRequester != null) it.focusRequester(focusRequester) + else it + }, + ) +} diff --git a/app/src/main/java/com/samco/trackandgraph/ui/viewmodels/DurationInputViewModel.kt b/app/src/main/java/com/samco/trackandgraph/ui/viewmodels/DurationInputViewModel.kt new file mode 100644 index 000000000..68fd1bcf6 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/viewmodels/DurationInputViewModel.kt @@ -0,0 +1,71 @@ +package com.samco.trackandgraph.ui.viewmodels + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import com.samco.trackandgraph.util.getDoubleFromTextOrNull +import org.threeten.bp.Duration + +interface DurationInputViewModel { + val hours: TextFieldValue + val minutes: TextFieldValue + val seconds: TextFieldValue + + fun setHoursText(value: TextFieldValue) + fun setMinutesText(value: TextFieldValue) + fun setSecondsText(value: TextFieldValue) + + fun setDurationFromDouble(value: Double) + fun getDurationAsDouble(): Double +} + +open class DurationInputViewModelImpl : DurationInputViewModel { + override var hours by mutableStateOf(TextFieldValue("")) + override var minutes by mutableStateOf(TextFieldValue("")) + override var seconds by mutableStateOf(TextFieldValue("")) + + private var onChange: ((Double) -> Unit)? = null + + private fun TextFieldValue.getDouble() = getDoubleFromTextOrNull(this.text ?: "") ?: 0.0 + + fun setOnChangeListener(onChange: (Double) -> Unit) { + this.onChange = onChange + } + + override fun setHoursText(value: TextFieldValue) { + hours = value.copy(text = value.text.asValidatedInt()) + this.onChange?.invoke(getDurationAsDouble()) + } + + override fun setMinutesText(value: TextFieldValue) { + minutes = value.copy(text = value.text.asValidatedInt()) + this.onChange?.invoke(getDurationAsDouble()) + } + + override fun setSecondsText(value: TextFieldValue) { + seconds = value.copy(text = value.text.asValidatedDouble()) + this.onChange?.invoke(getDurationAsDouble()) + } + + override fun setDurationFromDouble(value: Double) { + val duration = Duration.ofSeconds(value.toLong()) + val numHours = duration.toHours() + val numMinutes = duration.minusHours(numHours).toMinutes() + val numSeconds = duration.minusHours(numHours).minusMinutes(numMinutes).seconds + + val hrsString = numHours.toString() + val minString = numMinutes.toString() + val secString = numSeconds.toString() + + hours = TextFieldValue(numHours.toString(), TextRange(hrsString.length)) + minutes = TextFieldValue(numMinutes.toString(), TextRange(minString.length)) + seconds = TextFieldValue(numSeconds.toString(), TextRange(secString.length)) + this.onChange?.invoke(getDurationAsDouble()) + } + + override fun getDurationAsDouble(): Double { + return (hours.getDouble() * 3600.0) + (minutes.getDouble() * 60.0) + (seconds.getDouble()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/ui/viewmodels/Helpers.kt b/app/src/main/java/com/samco/trackandgraph/ui/viewmodels/Helpers.kt new file mode 100644 index 000000000..bb53f33e9 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/ui/viewmodels/Helpers.kt @@ -0,0 +1,27 @@ +package com.samco.trackandgraph.ui.viewmodels + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue + +fun String.asValidatedInt() = + this.take(1).filter { it.isDigit() || it == '-' } + this.drop(1).takeWhile { it.isDigit() } + +fun String.asValidatedDouble(): String { + val firstChar = this.take(1).filter { it.isDigit() || it == '-' || it == '.' } + val remainingChars = this.drop(1).filter { it.isDigit() || it == '.' } + if (remainingChars.count { it == '.' } > 1) { + val remainingCharsFiltered = remainingChars + .takeWhile { it != '.' } + '.' + remainingChars + .dropWhile { it != '.' } + .filter { it != '.' } + return firstChar + remainingCharsFiltered + } + return firstChar + remainingChars +} + +fun TextFieldValue.asValidatedInt() = this.copy(text = this.text.asValidatedInt()) + +fun TextFieldValue.asValidatedDouble() = this.copy(text = this.text.asValidatedDouble()) + +fun Double.asTextFieldValue() = TextFieldValue(this.toString(), TextRange(this.toString().length)) + diff --git a/app/src/main/java/com/samco/trackandgraph/util/BindingForViewLifecycle.kt b/app/src/main/java/com/samco/trackandgraph/util/BindingForViewLifecycle.kt new file mode 100644 index 000000000..2f92a8d5e --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/util/BindingForViewLifecycle.kt @@ -0,0 +1,48 @@ +package com.samco.trackandgraph.util + +import androidx.fragment.app.Fragment +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +//Code from https://medium.com/trade-me/handling-lifecycle-with-view-binding-in-fragments-a7f237c56832 + +fun Fragment.bindingForViewLifecycle(): ReadWriteProperty = + object : ReadWriteProperty, DefaultLifecycleObserver { + + // A backing property to hold our value + private var binding: T? = null + + init { + // Observe the View Lifecycle of the Fragment + this@bindingForViewLifecycle + .viewLifecycleOwnerLiveData + .observe(this@bindingForViewLifecycle) { + it.lifecycle.addObserver(this) + } + } + + //This should be called onDestroyView of the fragment + override fun onDestroy(owner: LifecycleOwner) { + super.onDestroy(owner) + binding = null + } + + override fun getValue( + thisRef: Fragment, + property: KProperty<*> + ): T { + // Return the backing property if it's set + return this.binding!! + } + + override fun setValue( + thisRef: Fragment, + property: KProperty<*>, + value: T + ) { + // Set the backing property + this.binding = value + } + } diff --git a/app/src/main/java/com/samco/trackandgraph/util/CSVReadWriter.kt b/app/src/main/java/com/samco/trackandgraph/util/CSVReadWriter.kt deleted file mode 100644 index e2817b16f..000000000 --- a/app/src/main/java/com/samco/trackandgraph/util/CSVReadWriter.kt +++ /dev/null @@ -1,193 +0,0 @@ -package com.samco.trackandgraph.util - -import com.samco.trackandgraph.R -import com.samco.trackandgraph.database.* -import kotlinx.coroutines.yield -import org.apache.commons.csv.CSVFormat -import org.apache.commons.csv.CSVPrinter -import org.apache.commons.csv.CSVRecord -import org.threeten.bp.OffsetDateTime -import java.io.InputStream -import java.io.OutputStream -import java.lang.Exception - -object CSVReadWriter { - enum class HEADERS { - FeatureName, - Timestamp, - Value - } - - suspend fun writeFeaturesToCSV(features: List, dataSource: TrackAndGraphDatabaseDao, outStream: OutputStream) { - outStream.writer().use { - val csvWriter = CSVPrinter(it, CSVFormat.DEFAULT.withHeader(HEADERS::class.java)) - for (feature in features) { - val dataPoints = dataSource.getDataPointsForFeatureSync(feature.id) - dataPoints.forEach { dp -> - var value = dp.value - if (feature.featureType == FeatureType.DISCRETE) { - value = DiscreteValue.fromDataPoint(dp).toString() - } - csvWriter.printRecord(feature.name, dp.timestamp.toString(), value) - yield() - } - } - it.flush() - } - } - - suspend fun readFeaturesFromCSV(dataSource: TrackAndGraphDatabaseDao, inputStream: InputStream, trackGroupId: Long) { - try { - inputStream.reader().use { - val records = CSVFormat.DEFAULT - .withHeader(HEADERS::class.java) - .withFirstRecordAsHeader() - .parse(it) - val headerMap = records.headerMap - validateHeaderMap(headerMap) - ingestRecords(dataSource, records, trackGroupId) - } - } catch (e: Exception) { - if (e is ImportFeaturesException) throw e - else throw ImportFeaturesException(R.string.import_exception_unknown) - } - } - - private suspend fun ingestRecords(dataSource: TrackAndGraphDatabaseDao, records: Iterable, trackGroupId: Long) { - val existingFeatures = dataSource.getFeaturesForTrackGroupSync(trackGroupId).toMutableList() - val existingFeaturesByName = existingFeatures.map { it.name to it }.toMap().toMutableMap() - val newDataPoints = mutableListOf() - - val insertFeature = {feature: Feature -> - existingFeatures.add(feature) - existingFeaturesByName[feature.name] = feature - } - - val validateNotDiscreteValueConflict = { discreteValue: DiscreteValue, feature: Feature, lineNumber: Int -> - if (feature.discreteValues.any { dv -> dv.index == discreteValue.index }) - throw ImportFeaturesException(R.string.import_exception_discrete_value_conflict, listOf(lineNumber.toString())) - } - - val addDiscreteValueToFeature = { feature: Feature, discreteValue: DiscreteValue, lineNumber: Int-> - validateNotDiscreteValueConflict(discreteValue, feature, lineNumber) - existingFeatures.remove(feature) - existingFeaturesByName.remove(feature.name) - val newFeature = tryAddDiscreteValueToFeature(dataSource, feature, discreteValue, lineNumber) - existingFeatures.add(newFeature) - existingFeaturesByName[newFeature.name] = newFeature - } - - val addContinuousDataPoint = { value: String, timestamp: OffsetDateTime, featureId: Long, lineNumber: Int -> - validateValueContinuous(value, lineNumber) - newDataPoints.add(DataPoint(timestamp, featureId, value, "")) - } - - val addDiscreteDataPoint = { value: String, timestamp: OffsetDateTime, feature: Feature, lineNumber: Int -> - val discreteValue = tryGetDiscreteValueFromString(value, lineNumber) - newDataPoints.add(DataPoint(timestamp, feature.id, discreteValue.index.toString(), discreteValue.label)) - discreteValue - } - - records.forEachIndexed { recordNumber, record -> - val lineNumber = recordNumber + 2 //one for index by zero and one for headers - val rec = getValidRecordData(record, lineNumber) - if (existingFeaturesByName.containsKey(rec.featureName)) { - val feature = existingFeaturesByName[rec.featureName]!! - if (getFeatureTypeForValue(rec.value) == FeatureType.DISCRETE) { - validateFeatureDiscrete(feature, lineNumber) - val discreteValue = addDiscreteDataPoint(rec.value, rec.timestamp, feature, lineNumber) - if (!feature.discreteValues.contains(discreteValue)) addDiscreteValueToFeature(feature, discreteValue, lineNumber) - } else { - validateFeatureContinuous(feature, lineNumber) - addContinuousDataPoint(rec.value, rec.timestamp, feature.id, lineNumber) - } - } else { - val newFeature = createNewFeature(dataSource, rec, trackGroupId, lineNumber) - insertFeature(newFeature) - if (newFeature.featureType == FeatureType.DISCRETE) addDiscreteDataPoint(rec.value, rec.timestamp, newFeature, lineNumber) - else addContinuousDataPoint(rec.value, rec.timestamp, newFeature.id, lineNumber) - } - if (lineNumber % 1000 == 0) { - dataSource.insertDataPoints(newDataPoints) - newDataPoints.clear() - } - yield() - } - if (newDataPoints.isNotEmpty()) dataSource.insertDataPoints(newDataPoints) - } - - private fun createNewFeature(dataSource: TrackAndGraphDatabaseDao, rec: RecordData, trackGroupId: Long, lineNumber: Int): Feature { - if (rec.featureName.length > MAX_FEATURE_NAME_LENGTH) throw ImportFeaturesException(R.string.import_exception_feature_name_too_long, listOf(lineNumber.toString())) - val featureType = getFeatureTypeForValue(rec.value) - val discreteValues = - if (featureType == FeatureType.DISCRETE) listOf(tryGetDiscreteValueFromString(rec.value, lineNumber)) - else listOf() - val newFeature = Feature.create(0, rec.featureName, trackGroupId, featureType, discreteValues, 0) - val featureId = dataSource.insertFeature(newFeature) - return newFeature.copy(id = featureId) - } - - private fun validateValueContinuous(value: String, lineNumber: Int) { - if (value.toDoubleOrNull() == null) throw ImportFeaturesException(R.string.import_exception_not_a_valid_number, listOf(lineNumber.toString())) - } - - private fun tryGetDiscreteValueFromString(string: String, lineNumber: Int): DiscreteValue { - try { return DiscreteValue.fromString(string) } - catch (e: Exception) { throw ImportFeaturesException(R.string.import_exception_bad_discrete_value, listOf(lineNumber.toString())) } - } - - private fun tryAddDiscreteValueToFeature(dataSource: TrackAndGraphDatabaseDao, feature: Feature, discreteValue: DiscreteValue, lineNumber: Int): Feature { - val newDiscreteValues = feature.discreteValues.toMutableList() - if (newDiscreteValues.size == MAX_DISCRETE_VALUES_PER_FEATURE) - throw ImportFeaturesException(R.string.import_exception_max_discrete_values_reached, listOf(MAX_DISCRETE_VALUES_PER_FEATURE.toString())) - if (newDiscreteValues.map { dv -> dv.index }.contains(discreteValue.index)) - throw ImportFeaturesException(R.string.import_exception_discrete_value_index_exists, listOf(lineNumber.toString(), discreteValue.index.toString())) - if (newDiscreteValues.map { dv -> dv.label }.contains(discreteValue.label)) - throw ImportFeaturesException(R.string.import_exception_discrete_value_label_exists, listOf(lineNumber.toString(), discreteValue.label)) - newDiscreteValues.add(discreteValue) - dataSource.updateFeature(feature.copy(discreteValues = newDiscreteValues)) - return feature.copy(discreteValues = newDiscreteValues) - } - - private fun validateFeatureContinuous(feature: Feature, lineNumber: Int) { - if (feature.featureType != FeatureType.CONTINUOUS) - throw ImportFeaturesException(R.string.import_exception_inconsistent_data_type, listOf(lineNumber.toString())) - } - - private fun validateFeatureDiscrete(feature: Feature, lineNumber: Int) { - if (feature.featureType != FeatureType.DISCRETE) - throw ImportFeaturesException(R.string.import_exception_inconsistent_data_type, listOf(lineNumber.toString())) - } - - private fun getFeatureTypeForValue(value: String): FeatureType { - return if (value.contains(':')) FeatureType.DISCRETE else FeatureType.CONTINUOUS - } - - class ImportFeaturesException(val stringId: Int, val stringArgs: List? = null) : Exception() - - private data class RecordData ( - val featureName: String, - val value: String, - val timestamp: OffsetDateTime - ) - - private fun getValidRecordData(record: CSVRecord, lineNumber: Int): RecordData { - if (!record.isConsistent) throw ImportFeaturesException(R.string.import_exception_inconsistent_record, listOf(lineNumber.toString())) - val featureName = record.get(HEADERS.FeatureName) - val timestamp = record.get(HEADERS.Timestamp) - val value = record.get(HEADERS.Value) - lateinit var parsedTimestamp: OffsetDateTime - try { parsedTimestamp = odtFromString(timestamp) } - catch (_: Exception) { throw ImportFeaturesException(R.string.import_exception_bad_timestamp, listOf(lineNumber.toString())) } - return RecordData(featureName, value, parsedTimestamp) - } - - private fun validateHeaderMap(headerIndexes: Map) { - val ourHeaders = HEADERS.values().map{ v -> v.name } - val exception = ImportFeaturesException(R.string.import_exception_bad_headers, listOf(ourHeaders.joinToString())) - if (headerIndexes.size != HEADERS.values().size) throw exception - HEADERS.values().forEach { - if (!headerIndexes.containsKey(it.name) || headerIndexes[it.name] != ourHeaders.indexOf(it.name)) throw exception - } - } -} diff --git a/app/src/main/java/com/samco/trackandgraph/util/FeatureDataProvider.kt b/app/src/main/java/com/samco/trackandgraph/util/FeatureDataProvider.kt new file mode 100644 index 000000000..24040c04d --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/util/FeatureDataProvider.kt @@ -0,0 +1,58 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ +package com.samco.trackandgraph.util + +import com.samco.trackandgraph.base.database.dto.Feature +import com.samco.trackandgraph.base.database.dto.Group +import com.samco.trackandgraph.base.database.sampling.DataSampleProperties + +class FeatureDataProvider( + private val dataSourceData: Map +) : FeaturePathProvider(dataSourceData.map { it.key.feature to it.value }.toMap()) { + + constructor(dataSourceData: List, groups: List) : this( + dataSourceData.mapNotNull { dataSource -> + val group = groups.firstOrNull { it.id == dataSource.feature.groupId } + ?: return@mapNotNull null + dataSource to group + }.toMap() + ) + + fun dataSourceDataAlphabetically() = + dataSourceData.keys.map { + DataSourceDataWithPath( + it.feature, + it.dataProperties, + getPathForFeature(it.feature.featureId) + ) + }.sortedBy { it.path } + + fun getDataSampleProperties(featureId: Long) = dataSourceData.keys.firstOrNull { + it.feature.featureId == featureId + }?.dataProperties + + data class DataSourceData( + val feature: Feature, + val dataProperties: DataSampleProperties? + ) + + data class DataSourceDataWithPath( + val feature: Feature, + val dataProperties: DataSampleProperties?, + val path: String + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/util/FeaturePathProvider.kt b/app/src/main/java/com/samco/trackandgraph/util/FeaturePathProvider.kt new file mode 100644 index 000000000..793068be8 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/util/FeaturePathProvider.kt @@ -0,0 +1,53 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.util + +import com.samco.trackandgraph.base.database.dto.Feature +import com.samco.trackandgraph.base.database.dto.Group + +open class FeaturePathProvider( + private val featureGroupMap: Map, +) : GroupPathProvider(featureGroupMap.values) { + + constructor(features: List, groups: List) : this( + features.mapNotNull { feature -> + val group = groups.firstOrNull { it.id == feature.groupId } + ?: return@mapNotNull null + feature to group + }.toMap() + ) + + fun sortedFeatureMap() = sortedPaths().associate { it.first.featureId to it.second } + + val features get() = featureGroupMap.keys + + fun sortedAlphabetically() = featureGroupMap.keys.sortedBy { getPathForFeature(it.featureId) } + + fun sortedPaths() = featureGroupMap.keys + .map { it to getPathForFeature(it.featureId) } + .sortedBy { it.second } + + fun getPathForFeature(featureId: Long): String { + val dataSource = featureGroupMap.keys.firstOrNull { it.featureId == featureId } ?: return "" + val group = featureGroupMap[dataSource] ?: return "" + val groupPath = getPathForGroup(group.id) + var path = groupPath + if (groupPath.lastOrNull() != '/') path += '/' + return path + dataSource.name + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/util/FlowExtensions.kt b/app/src/main/java/com/samco/trackandgraph/util/FlowExtensions.kt new file mode 100644 index 000000000..160ece518 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/util/FlowExtensions.kt @@ -0,0 +1,49 @@ +package com.samco.trackandgraph.util + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.launch + +fun Flow.flatMapLatestScan( + initial: R, + transform: suspend (R, T) -> Flow +): Flow = channelFlow { + var previousFlow: Job? = null + var lastElement: R = initial + + collect { value -> + previousFlow?.apply { + cancel() + join() + } + + previousFlow = launch(start = CoroutineStart.UNDISPATCHED) { + transform(lastElement, value).collect { transformedValue -> + lastElement = transformedValue + send(transformedValue) + } + } + } +} + +fun Flow.debounceBuffer(timeoutMillis: Long): Flow> = channelFlow { + val currentList = mutableListOf() + var delayedEmitJob: Job? = null + + collect { item -> + if (delayedEmitJob?.isActive == true) delayedEmitJob?.cancel() + + currentList.add(item) + + delayedEmitJob = launch { + delay(timeoutMillis) + if (currentList.isNotEmpty()) send(currentList.toList()) + currentList.clear() + } + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/util/GroupPathProvider.kt b/app/src/main/java/com/samco/trackandgraph/util/GroupPathProvider.kt new file mode 100644 index 000000000..48e576c36 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/util/GroupPathProvider.kt @@ -0,0 +1,65 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.util + +import com.samco.trackandgraph.base.database.dto.Group + +open class GroupPathProvider( + val groups: Collection, + private val groupFilterId: Long? = null +) { + private val separator = "/" + + private val groupsById = groups.associateBy { it.id } + + private val groupParentChains = groups.associate { group -> + group.id to run { + val chain = mutableListOf() + var currentGroup = group + do { + chain.add(currentGroup) + currentGroup = groupsById[currentGroup.parentGroupId] ?: break + + // It shouldn't be possible in the current version to create an infinite + // loop, but legacy versions may have a bug that allows this, so we must break + // out of the loop if we detect it. + } while (!chain.contains(currentGroup)) + return@run chain + } + } + + private val filteredGroupChains = groupParentChains + .filter { (_, chain) -> chain.none { it.id == groupFilterId } } + + val filteredGroups = filteredGroupChains + .map { (_, chain) -> chain.first() } + + private val groupPaths = filteredGroupChains + .mapValues { (_, chain) -> + chain + //Iterate from root to leaf + .asReversed() + //Ignore the root group, as it has no name + .filter { it.parentGroupId != null } + //Join to / separated string starting with the root / + .joinToString(separator = separator, prefix = separator) { it.name } + } + + + fun getPathForGroup(id: Long): String = groupPaths[id] ?: "" +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/util/ImportExportFeatureUtils.kt b/app/src/main/java/com/samco/trackandgraph/util/ImportExportFeatureUtils.kt index 0326ea73f..0b92fad5d 100644 --- a/app/src/main/java/com/samco/trackandgraph/util/ImportExportFeatureUtils.kt +++ b/app/src/main/java/com/samco/trackandgraph/util/ImportExportFeatureUtils.kt @@ -1,23 +1,36 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ package com.samco.trackandgraph.util import android.app.Activity -import android.content.Context import android.net.Uri import android.provider.OpenableColumns import android.widget.Button import androidx.appcompat.app.AlertDialog -import androidx.core.content.ContextCompat -import com.samco.trackandgraph.R object ImportExportFeatureUtils { - fun setFileButtonTextFromUri(activity: Activity?, context: Context, uri: Uri, fileButton: Button, alertDialog: AlertDialog) { + fun setFileButtonTextFromUri(activity: Activity?, uri: Uri, fileButton: Button, alertDialog: AlertDialog) { val projection = arrayOf(OpenableColumns.DISPLAY_NAME) val cursor = activity?.contentResolver?.query(uri, projection, null, null, null) val index = cursor?.getColumnIndex(OpenableColumns.DISPLAY_NAME) cursor?.moveToFirst() if (cursor != null && index != null) { fileButton.text = cursor.getString(index) - fileButton.setTextColor(ContextCompat.getColor(context, R.color.regularText)) + fileButton.setTextColor(fileButton.context.getColorFromAttr(android.R.attr.textColorPrimary)) alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = true } cursor?.close() diff --git a/app/src/main/java/com/samco/trackandgraph/util/Stopwatch.kt b/app/src/main/java/com/samco/trackandgraph/util/Stopwatch.kt new file mode 100644 index 000000000..c511f689f --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/util/Stopwatch.kt @@ -0,0 +1,29 @@ +package com.samco.trackandgraph.util + +class Stopwatch { + private var startTime: Long = 0 + private var endTime: Long = 0 + private var isRunning = false + + fun start() { + startTime = System.nanoTime() + isRunning = true + } + + fun stop() { + endTime = System.nanoTime() + isRunning = false + } + + val elapsedMillis: Long + get() = getElapsedTimeMillis() + + private fun getElapsedNanos(): Long { + return if (isRunning) System.nanoTime() - startTime + else endTime -startTime + } + + private fun getElapsedTimeMillis(): Long { + return getElapsedNanos() / 1000000 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/util/UtilFuncs.kt b/app/src/main/java/com/samco/trackandgraph/util/UtilFuncs.kt new file mode 100644 index 000000000..fa4e45fbe --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/util/UtilFuncs.kt @@ -0,0 +1,134 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.util + +import android.content.Context +import android.os.* +import android.util.TypedValue +import android.view.View +import android.view.ViewTreeObserver +import android.view.Window +import android.view.inputmethod.InputMethodManager +import androidx.annotation.AttrRes +import androidx.annotation.ColorInt +import androidx.appcompat.app.AppCompatActivity +import java.lang.NumberFormatException + +fun getDoubleFromText(text: String): Double = getDoubleFromTextOrNull(text) ?: 0.0 + +/** + * Return a number given a string by attempting to parse it as a double + * + * This function should account for different langauge punctuation formats. e.g. + * French: 1.345.6,04 + * English: 1,345,6.04 + * Both of these inputs will return 13456.04 + * + * This function will always return a number rather than throw an exception. If the number + * couldn't be parsed then the returned value will default to 0 + */ +fun getDoubleFromTextOrNull(text: String): Double? { + try { + //first account for values like 1,345,100 or French: 1.345.100 + val commaCount = text.count { c -> c == ',' } + val dotCount = text.count { c -> c == '.' } + if ((commaCount == 0 && dotCount > 1) || (dotCount == 0 && commaCount > 1)) + return text.replace(",", "").replace(".", "").toDouble() + + //If there is a mixture of commas and dots convert all commas to dots + val dotsOnly = text.replace(",", ".") + val lastDot = dotsOnly.indexOfLast { c -> c == '.' } + //If there are no commas or dots then there is no decimal place + if (lastDot < 0) return dotsOnly.toDouble() + + //If there is at least one comma or dot then the last one represents the decimal place + val before = dotsOnly.substring(0, lastDot).replace(".", "") + val after = dotsOnly.substring(lastDot) + return "$before$after".toDouble() + } catch (e: NumberFormatException) { + return null + } +} + +@ColorInt +fun Context.getColorFromAttr( + @AttrRes attrColor: Int, + typedValue: TypedValue = TypedValue(), + resolveRefs: Boolean = true +): Int { + theme.resolveAttribute(attrColor, typedValue, resolveRefs) + return typedValue.data +} + +fun View.focusAndShowKeyboard() { + //https://stackoverflow.com/a/71587766/13310191 + /** + * This is to be called when the window already has focus. + */ + fun View.showTheKeyboardNow() { + if (isFocused) { + post { + // We still post the call, just in case we are being notified of the windows focus + // but InputMethodManager didn't get properly setup yet. + val imm = + context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) + } + } + } + + requestFocus() + if (hasWindowFocus()) { + // No need to wait for the window to get focus. + showTheKeyboardNow() + } else { + // We need to wait until the window gets focus. + viewTreeObserver.addOnWindowFocusChangeListener( + object : ViewTreeObserver.OnWindowFocusChangeListener { + override fun onWindowFocusChanged(hasFocus: Boolean) { + // This notification will arrive just before the InputMethodManager gets set up. + if (hasFocus) { + this@focusAndShowKeyboard.showTheKeyboardNow() + // Itโ€™s very important to remove this listener once we are done. + viewTreeObserver.removeOnWindowFocusChangeListener(this) + } + } + }) + } +} + +fun Window.hideKeyboard(windowToken: IBinder? = null, flags: Int = 0) { + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(windowToken ?: decorView.windowToken, flags) +} + +@Suppress("DEPRECATION") +fun Context.performTrackVibrate() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = + getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + val vibrator = vibratorManager.defaultVibrator + vibrator.vibrate(VibrationEffect.createOneShot(100, VibrationEffect.DEFAULT_AMPLITUDE)) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val vibrator = getSystemService(AppCompatActivity.VIBRATOR_SERVICE) as Vibrator + vibrator.vibrate(VibrationEffect.createOneShot(100, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + val vibrator = getSystemService(AppCompatActivity.VIBRATOR_SERVICE) as Vibrator + vibrator.vibrate(100) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/viewgraphstat/GraphNote.kt b/app/src/main/java/com/samco/trackandgraph/viewgraphstat/GraphNote.kt new file mode 100644 index 000000000..811494700 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/viewgraphstat/GraphNote.kt @@ -0,0 +1,62 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.viewgraphstat + +import com.samco.trackandgraph.base.database.dto.DataPoint +import com.samco.trackandgraph.base.database.dto.GlobalNote +import org.threeten.bp.OffsetDateTime + +class GraphNote { + val dataPoint: DataPoint? + val globalNote: GlobalNote? + val timestamp: OffsetDateTime + + constructor(dataPoint: DataPoint) { + this.dataPoint = dataPoint + this.globalNote = null + this.timestamp = dataPoint.timestamp + } + + constructor(globalNote: GlobalNote) { + this.globalNote = globalNote + this.dataPoint = null + this.timestamp = globalNote.timestamp + } + + fun isDataPoint() = dataPoint != null + fun isGlobalNote() = globalNote != null + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as GraphNote + + if (dataPoint != other.dataPoint) return false + if (globalNote != other.globalNote) return false + + return true + } + + override fun hashCode(): Int { + var result = dataPoint?.hashCode() ?: 0 + result = 31 * result + (globalNote?.hashCode() ?: 0) + result = 31 * result + timestamp.hashCode() + return result + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/viewgraphstat/NotesAdapter.kt b/app/src/main/java/com/samco/trackandgraph/viewgraphstat/NotesAdapter.kt new file mode 100644 index 000000000..ae7fa1784 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/viewgraphstat/NotesAdapter.kt @@ -0,0 +1,127 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.viewgraphstat + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.samco.trackandgraph.base.helpers.formatDayMonthYearHourMinuteWeekDayOneLine +import com.samco.trackandgraph.base.helpers.getDisplayValue +import com.samco.trackandgraph.databinding.ListItemNoteBinding +import com.samco.trackandgraph.util.FeatureDataProvider + +class NotesAdapter( + private val featureDataProvider: FeatureDataProvider, + private val weekDayNames: List, + private val clickListener: NoteClickListener +) : ListAdapter( + NoteDiffCallback() +) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder.from( + parent, + weekDayNames, + clickListener + ) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position), featureDataProvider) + } + + class ViewHolder private constructor( + private val binding: ListItemNoteBinding, + private val weekDayNames: List, + private val clickListener: NoteClickListener + ) : RecyclerView.ViewHolder(binding.root) { + + var note: GraphNote? = null + + fun bind(note: GraphNote, featureDataProvider: FeatureDataProvider) { + this.note = note + binding.timestampText.text = + formatDayMonthYearHourMinuteWeekDayOneLine( + binding.timestampText.context, + weekDayNames, + note.timestamp + ) + when { + note.isDataPoint() -> initFromDataPointNote(featureDataProvider) + else -> initFromGlobalNote() + } + } + + private fun initFromGlobalNote() { + val globalNote = note!!.globalNote!! + binding.valueText.visibility = View.GONE + binding.trackerNameText.visibility = View.GONE + binding.cardView.setOnClickListener { clickListener.viewClicked(note!!) } + binding.noteText.visibility = View.VISIBLE + binding.noteText.text = globalNote.note + } + + private fun initFromDataPointNote(featureDataProvider: FeatureDataProvider) { + val dataPoint = note!!.dataPoint!! + binding.valueText.visibility = View.VISIBLE + val isDuration = featureDataProvider.getDataSampleProperties(dataPoint.featureId) + ?.isDuration ?: false + binding.valueText.text = note!!.dataPoint!!.getDisplayValue(isDuration) + binding.trackerNameText.visibility = View.VISIBLE + binding.trackerNameText.text = + featureDataProvider.getPathForFeature(dataPoint.featureId) + binding.cardView.setOnClickListener { clickListener.viewClicked(note!!) } + binding.noteText.visibility = View.VISIBLE + binding.noteText.text = dataPoint.note + } + + companion object { + fun from( + parent: ViewGroup, + weekDayNames: List, + clickListener: NoteClickListener + ): ViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = ListItemNoteBinding.inflate(layoutInflater, parent, false) + return ViewHolder( + binding, + weekDayNames, + clickListener + ) + } + } + } +} + +class NoteDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: GraphNote, newItem: GraphNote) = + oldItem.timestamp == newItem.timestamp + && oldItem.dataPoint?.featureId == newItem.dataPoint?.featureId + && oldItem.globalNote?.timestamp == newItem.globalNote?.timestamp + + override fun areContentsTheSame(oldItem: GraphNote, newItem: GraphNote) = + oldItem.dataPoint == newItem.dataPoint + && oldItem.globalNote == newItem.globalNote +} + +class NoteClickListener(private val onViewDataPoint: (GraphNote) -> Unit) { + fun viewClicked(note: GraphNote) = onViewDataPoint(note) +} diff --git a/app/src/main/java/com/samco/trackandgraph/viewgraphstat/ViewGraphStatFragment.kt b/app/src/main/java/com/samco/trackandgraph/viewgraphstat/ViewGraphStatFragment.kt new file mode 100644 index 000000000..deaa1dd98 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/viewgraphstat/ViewGraphStatFragment.kt @@ -0,0 +1,246 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ + +package com.samco.trackandgraph.viewgraphstat + +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.content.res.Configuration +import android.graphics.Point +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.animation.LinearInterpolator +import android.widget.LinearLayout +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Surface +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.NavController +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.helpers.getWeekDayNames +import com.samco.trackandgraph.databinding.FragmentViewGraphStatBinding +import com.samco.trackandgraph.graphstatview.ui.GraphStatView +import com.samco.trackandgraph.ui.compose.theming.TnGComposeTheme +import com.samco.trackandgraph.ui.showDataPointDescriptionDialog +import com.samco.trackandgraph.ui.showNoteDialog +import com.samco.trackandgraph.util.bindingForViewLifecycle +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class ViewGraphStatFragment : Fragment() { + private var navController: NavController? = null + private val viewModel: ViewGraphStatViewModel by viewModels() + private var binding: FragmentViewGraphStatBinding by bindingForViewLifecycle() + private lateinit var adapter: NotesAdapter + private val args: ViewGraphStatFragmentArgs by navArgs() + private val windowSize = Point() + private val maxGraphHeightRatioPortrait = 0.77f + private val minGraphHeightRatioPortrait = 0.3f + private val maxGraphHeightRatioLandscape = 0.7f + private val minGraphHeightRatioLandscape = 0f + + private var graphHeight by mutableStateOf(0) + + private var showHideNotesAnimator: ValueAnimator? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + this.navController = container?.findNavController() + binding = FragmentViewGraphStatBinding.inflate(inflater, container, false) + + viewModel.initFromGraphStatId(args.graphStatId) + + setViewInitialState() + listenToShowingNotes() + listenToFeaturePathProvider() + listenToBinding() + return binding.root + } + + private fun listenToFeaturePathProvider() { + viewModel.featureDataProvider.observe(viewLifecycleOwner) { featureDataProvider -> + adapter = NotesAdapter( + featureDataProvider, + getWeekDayNames(requireContext()), + NoteClickListener(this::onViewNoteClicked) + ) + binding.notesRecyclerView.adapter = adapter + binding.notesRecyclerView.layoutManager = LinearLayoutManager( + context, RecyclerView.VERTICAL, false + ) + viewModel.notes.observe(viewLifecycleOwner) { onNewNotesList(it) } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + requireActivity().windowManager.defaultDisplay.getSize(windowSize) + val heightRatio = + if (isPortrait()) maxGraphHeightRatioPortrait else maxGraphHeightRatioLandscape + graphHeight = (windowSize.y * heightRatio).toInt() + + setUpComposeView() + } + + private fun setUpComposeView() { + binding.composeView.setContent { + TnGComposeTheme { + Surface( + modifier = Modifier.fillMaxSize(), + ) { + val timeMarker = viewModel.markedNote.observeAsState() + val viewData = viewModel.graphStatViewData.observeAsState() + + if (viewData.value != null) { + GraphStatView( + modifier = Modifier.padding(dimensionResource(id = R.dimen.card_padding)), + graphStatViewData = viewData.value!!, + listMode = false, + timeMarker = timeMarker.value?.timestamp, + graphHeight = graphHeight + ) + } + } + } + } + } + + private fun setViewInitialState() { + binding.showNotesButton.visibility = View.GONE + } + + private fun onViewNoteClicked(note: GraphNote) { + viewModel.noteClicked(note) + when { + note.isGlobalNote() -> showNoteDialog( + layoutInflater, + requireContext(), + note.globalNote!! + ) + note.isDataPoint() -> { + val dataPoint = note.dataPoint!! + val dataProvider = viewModel.featureDataProvider.value + val featureDisplayName = dataProvider?.getPathForFeature(dataPoint.featureId) ?: "" + val isDuration = dataProvider?.getDataSampleProperties(dataPoint.featureId) + ?.isDuration ?: false + showDataPointDescriptionDialog( + requireContext(), + layoutInflater, + dataPoint, + isDuration, + featureDisplayName + ) + } + } + } + + @SuppressLint("ClickableViewAccessibility") + private fun listenToBinding() { + binding.showNotesButton.setOnTouchListener { _, motionEvent -> + if (motionEvent?.action == MotionEvent.ACTION_DOWN) { + viewModel.showHideNotesClicked() + return@setOnTouchListener true + } + return@setOnTouchListener false + } + } + + private fun listenToShowingNotes() { + viewModel.showingNotes.observe(viewLifecycleOwner) { b -> onShowingNotesChanged(b) } + } + + private fun onNewNotesList(notes: List) { + if (notes.isEmpty()) return + binding.showNotesButton.visibility = View.VISIBLE + adapter.submitList(notes) + } + + private fun onShowingNotesChanged(showNotes: Boolean) { + val maxGraphHeightRatio = + if (isPortrait()) maxGraphHeightRatioPortrait else maxGraphHeightRatioLandscape + val minGraphHeightRatio = + if (isPortrait()) minGraphHeightRatioPortrait else minGraphHeightRatioLandscape + val maxNotesHeight = windowSize.y * (maxGraphHeightRatio - minGraphHeightRatio) + val targetNotesHeight = if (showNotes) maxNotesHeight else 0 + val targetGraphHeight = + windowSize.y * (if (showNotes) minGraphHeightRatio else maxGraphHeightRatio) + + animateViewHeights(targetNotesHeight.toInt(), targetGraphHeight.toInt()) + binding.notesPopupUpButton.visibility = if (showNotes) View.GONE else View.VISIBLE + binding.notesPopupDownButton.visibility = if (showNotes) View.VISIBLE else View.GONE + } + + private fun isPortrait() = + resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT + + private fun animateViewHeights(notesTargetHeight: Int, graphTargetHeight: Int) { + showHideNotesAnimator?.cancel() + val startingNotesHeight = + (binding.notesRecyclerView.layoutParams as LinearLayout.LayoutParams).height + val startingGraphHeight = graphHeight + showHideNotesAnimator = ValueAnimator.ofFloat(0f, 1f) + showHideNotesAnimator?.apply { + duration = resources.getInteger(android.R.integer.config_shortAnimTime).toLong() + interpolator = LinearInterpolator() + addUpdateListener { animation -> + val nextRatio = animation.animatedValue as Float + val nextGraphHeight = + startingGraphHeight + ((graphTargetHeight - startingGraphHeight) * nextRatio) + val nextNotesHeight = + startingNotesHeight + ((notesTargetHeight - startingNotesHeight) * nextRatio) + graphHeight = nextGraphHeight.toInt() + (binding.notesRecyclerView.layoutParams as LinearLayout.LayoutParams).height = + nextNotesHeight.toInt() + binding.notesRecyclerView.requestLayout() + } + }?.start() + } + + override fun onDestroyView() { + showHideNotesAnimator?.cancel() + super.onDestroyView() + } + + override fun onResume() { + super.onResume() + (activity as AppCompatActivity).supportActionBar!!.hide() + } + + override fun onStop() { + super.onStop() + (activity as AppCompatActivity).supportActionBar!!.show() + } +} + diff --git a/app/src/main/java/com/samco/trackandgraph/viewgraphstat/ViewGraphStatViewModel.kt b/app/src/main/java/com/samco/trackandgraph/viewgraphstat/ViewGraphStatViewModel.kt new file mode 100644 index 000000000..f8f4f72f8 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/viewgraphstat/ViewGraphStatViewModel.kt @@ -0,0 +1,140 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.viewgraphstat + +import androidx.lifecycle.* +import com.samco.trackandgraph.base.database.dto.DataPoint +import com.samco.trackandgraph.base.database.dto.GlobalNote +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.base.model.di.MainDispatcher +import com.samco.trackandgraph.util.FeatureDataProvider +import com.samco.trackandgraph.graphstatproviders.GraphStatInteractorProvider +import com.samco.trackandgraph.graphstatview.factories.viewdto.IGraphStatViewData +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + + +interface ViewGraphStatViewModel { + fun initFromGraphStatId(graphStatId: Long) + + //TODO This won't work when we implement features. FeatureDataProvider + // iterates all data in every feature + val featureDataProvider: LiveData + val graphStatViewData: LiveData + val showingNotes: LiveData + val markedNote: LiveData + val notes: LiveData> + + fun showHideNotesClicked() + fun noteClicked(note: GraphNote) +} + +@HiltViewModel +class ViewGraphStatViewModelImpl @Inject constructor( + private val dataInteractor: DataInteractor, + private val gsiProvider: GraphStatInteractorProvider, + @MainDispatcher private val ui: CoroutineDispatcher, + @IODispatcher private val io: CoroutineDispatcher +) : ViewModel(), ViewGraphStatViewModel { + override val featureDataProvider = MutableLiveData() + override val graphStatViewData = MutableLiveData() + override val showingNotes = MutableLiveData(false) + + private val globalNotes = MutableStateFlow>(emptyList()) + private val dataPoints = MutableStateFlow>(emptyList()) + + override val notes: LiveData> = + combine(dataPoints, globalNotes) { dataPoints, globalNotes -> + val distinctDataPoints = dataPoints.distinct() + val oldestTime = distinctDataPoints.minByOrNull { it.timestamp }?.timestamp + val newestTime = distinctDataPoints.maxByOrNull { it.timestamp }?.timestamp + + if (oldestTime == null || newestTime == null) return@combine emptyList() + + val filteredGlobalNotes = globalNotes + .filter { g -> g.timestamp in oldestTime..newestTime } + .map { GraphNote(it) } + + distinctDataPoints + .filter { dp -> dp.note.isNotEmpty() } + .distinct() + .map { GraphNote(it) } + .union(filteredGlobalNotes) + .sortedByDescending { it.timestamp } + }.asLiveData(viewModelScope.coroutineContext) + + override val markedNote = MutableLiveData(null) + + private var initialized = false + + override fun initFromGraphStatId(graphStatId: Long) { + if (initialized) return + initialized = true + + viewModelScope.launch(io) { + emitGraphData(graphStatId) + getAllDataSourceAttributes() + getAllGlobalNotes() + } + } + + private suspend fun getAllGlobalNotes() = withContext(io) { + val n = dataInteractor.getAllGlobalNotesSync() + globalNotes.emit(n) + } + + private suspend fun getAllDataSourceAttributes() { + val allFeatures = dataInteractor.getAllFeaturesSync() + val allGroups = dataInteractor.getAllGroupsSync() + val dataSourceData = allFeatures.map { feature -> + FeatureDataProvider.DataSourceData( + feature, + dataInteractor.getDataSamplePropertiesForFeatureId(feature.featureId) + ?: return@map null + ) + }.filterNotNull() + FeatureDataProvider(dataSourceData, allGroups).let { + withContext(ui) { + featureDataProvider.value = it + } + } + } + + private suspend fun emitGraphData(graphStatId: Long) { + val graphStat = dataInteractor.getGraphStatById(graphStatId) + withContext(ui) { graphStatViewData.value = IGraphStatViewData.loading(graphStat) } + val viewData = gsiProvider + .getDataFactory(graphStat.type) + .getViewData(graphStat) { viewModelScope.launch(io) { dataPoints.emit(it) } } + withContext(ui) { graphStatViewData.value = viewData } + } + + override fun showHideNotesClicked() { + showingNotes.value = showingNotes.value?.not() + } + + override fun noteClicked(note: GraphNote) { + markedNote.value = note + } +} \ No newline at end of file diff --git a/app/src/main/java/com/samco/trackandgraph/widgets/TrackWidgetConfigureActivity.kt b/app/src/main/java/com/samco/trackandgraph/widgets/TrackWidgetConfigureActivity.kt new file mode 100644 index 000000000..315e8129b --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/widgets/TrackWidgetConfigureActivity.kt @@ -0,0 +1,87 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.widgets + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.* +import androidx.appcompat.app.AppCompatActivity +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.service.TrackWidgetProvider +import com.samco.trackandgraph.base.service.TrackWidgetProvider.Companion.WIDGET_PREFS_NAME +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class TrackWidgetConfigureActivity : AppCompatActivity(), + TrackWidgetConfigureDialog.TrackWidgetConfigureDialogListener { + private var appWidgetId: Int? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val dialog = TrackWidgetConfigureDialog() + supportFragmentManager.let { dialog.show(it, "create_track_widget_dialog") } + appWidgetId = intent?.extras + ?.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) + ?: AppWidgetManager.INVALID_APPWIDGET_ID + + setResult(RESULT_CANCELED) + } + + override fun onCreateWidget(featureId: Long?) { + if (appWidgetId == null) { + finish() + return + } + + if (featureId == null) { + val errorMessage = getString(R.string.track_widget_configure_no_data_selected_error) + Toast.makeText(applicationContext, errorMessage, Toast.LENGTH_LONG).show() + return + } + + val sharedPref = getSharedPreferences(WIDGET_PREFS_NAME, Context.MODE_PRIVATE).edit() + sharedPref.putLong(TrackWidgetProvider.getFeatureIdPref(appWidgetId!!), featureId) + sharedPref.apply() + + val intent = Intent( + AppWidgetManager.ACTION_APPWIDGET_UPDATE, + null, + this, + TrackWidgetProvider::class.java + ) + intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(appWidgetId!!)) + sendBroadcast(intent) + + val resultValue = Intent().apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId!!) + } + setResult(RESULT_OK, resultValue) + finish() + } + + override fun onNoFeatures() { + val errorString = getString(R.string.track_widget_configure_no_data_error) + Toast.makeText(applicationContext, errorString, Toast.LENGTH_LONG).show() + finish() + } + + override fun onDismiss() { + finish() + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/widgets/TrackWidgetConfigureDialog.kt b/app/src/main/java/com/samco/trackandgraph/widgets/TrackWidgetConfigureDialog.kt new file mode 100644 index 000000000..ec5444cd9 --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/widgets/TrackWidgetConfigureDialog.kt @@ -0,0 +1,154 @@ +/* + * This file is part of Track & Graph + * + * Track & Graph is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Track & Graph is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Track & Graph. If not, see . + */ + +package com.samco.trackandgraph.widgets + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.samco.trackandgraph.R +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.base.model.di.MainDispatcher +import com.samco.trackandgraph.databinding.TrackWidgetConfigureDialogBinding +import com.samco.trackandgraph.util.FeaturePathProvider +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@AndroidEntryPoint +class TrackWidgetConfigureDialog : DialogFragment() { + private val viewModel by viewModels() + private lateinit var binding: TrackWidgetConfigureDialogBinding + private lateinit var listener: TrackWidgetConfigureDialogListener + + internal interface TrackWidgetConfigureDialogListener { + fun onCreateWidget(featureId: Long?) + fun onNoFeatures() + fun onDismiss() + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return activity?.let { + binding = TrackWidgetConfigureDialogBinding.inflate(it.layoutInflater, null, false) + listener = activity as TrackWidgetConfigureDialogListener + + val alertDialog = MaterialAlertDialogBuilder(it, R.style.AppTheme_AlertDialogTheme) + .setView(binding.root) + .setPositiveButton(R.string.create) { _, _ -> + viewModel.onCreateClicked() + } + .setNegativeButton(R.string.cancel) { _, _ -> + dismiss() + listener.onDismiss() + } + .create() + + alertDialog.setCanceledOnTouchOutside(true) + alertDialog.setOnShowListener { setAlertDialogShowListeners() } + alertDialog + } ?: throw IllegalStateException("Activity cannot be null") + } + + private fun setAlertDialogShowListeners() { + observeAllFeatures() + observeOnCreateClicked() + } + + private fun observeOnCreateClicked() { + viewModel.onCreateWidget.observe(this) { + listener.onCreateWidget(it) + } + } + + private fun observeAllFeatures() = + viewModel.featurePathProvider.observe(this) { featurePathProvider -> + val sortedFeatures = featurePathProvider.sortedPaths() + if (sortedFeatures.isEmpty()) listener.onNoFeatures() + val itemNames = sortedFeatures.map { it.second } + val adapter = ArrayAdapter( + requireContext(), + android.R.layout.simple_spinner_dropdown_item, + itemNames + ) + binding.trackerSpinner.adapter = adapter + binding.trackerSpinner.setSelection(0) + binding.trackerSpinner.onItemSelectedListener = + object : AdapterView.OnItemSelectedListener { + override fun onNothingSelected(parent: AdapterView<*>?) {} + override fun onItemSelected( + parent: AdapterView<*>?, + view: View?, + position: Int, + id: Long + ) { + viewModel.onFeatureSelected(sortedFeatures[position].first.featureId) + } + } + } + + override fun onDismiss(dialog: DialogInterface) { + listener.onDismiss() + } +} + +@HiltViewModel +class TrackWidgetConfigureDialogViewModel @Inject constructor( + private val dataInteractor: DataInteractor, + @IODispatcher private val io: CoroutineDispatcher, + @MainDispatcher private val ui: CoroutineDispatcher +) : ViewModel() { + + private val _featurePathProvider = MutableLiveData() + val featurePathProvider: LiveData get() = _featurePathProvider + + private val _onCreateWidget = MutableLiveData() + val onCreateWidget: LiveData get() = _onCreateWidget + + private var selectedFeatureId: Long? = null + + init { + viewModelScope.launch(io) { + val groups = dataInteractor.getAllGroupsSync() + val trackers = dataInteractor.getAllTrackersSync() + withContext(ui) { + _featurePathProvider.value = FeaturePathProvider(trackers, groups) + } + } + } + + fun onCreateClicked() { + _onCreateWidget.value = selectedFeatureId + } + + fun onFeatureSelected(featureId: Long) { + selectedFeatureId = featureId + } +} diff --git a/app/src/main/java/com/samco/trackandgraph/widgets/TrackWidgetInputDataPointActivity.kt b/app/src/main/java/com/samco/trackandgraph/widgets/TrackWidgetInputDataPointActivity.kt new file mode 100644 index 000000000..30620291c --- /dev/null +++ b/app/src/main/java/com/samco/trackandgraph/widgets/TrackWidgetInputDataPointActivity.kt @@ -0,0 +1,142 @@ +/* +* This file is part of Track & Graph +* +* Track & Graph is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* Track & Graph is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with Track & Graph. If not, see . +*/ +package com.samco.trackandgraph.widgets + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.os.Bundle +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.samco.trackandgraph.adddatapoint.AddDataPointsDialog +import com.samco.trackandgraph.adddatapoint.AddDataPointsViewModelImpl +import com.samco.trackandgraph.base.database.dto.DataPoint +import com.samco.trackandgraph.base.database.dto.Tracker +import com.samco.trackandgraph.base.model.DataInteractor +import com.samco.trackandgraph.base.model.di.IODispatcher +import com.samco.trackandgraph.base.model.di.MainDispatcher +import com.samco.trackandgraph.base.service.TrackWidgetProvider +import com.samco.trackandgraph.base.service.TrackWidgetProvider.Companion.WIDGET_PREFS_NAME +import com.samco.trackandgraph.settings.TngSettings +import com.samco.trackandgraph.ui.compose.compositionlocals.LocalSettings +import com.samco.trackandgraph.util.hideKeyboard +import com.samco.trackandgraph.util.performTrackVibrate +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.* +import org.threeten.bp.OffsetDateTime +import javax.inject.Inject + +@AndroidEntryPoint +class TrackWidgetInputDataPointActivity : AppCompatActivity() { + private val viewModel by viewModels() + + private val addDataPointDialogViewModel by viewModels() + + @Inject + lateinit var tngSettings: TngSettings + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val composeView = ComposeView(this).apply { + setContent { + CompositionLocalProvider(LocalSettings provides tngSettings) { + AddDataPointsDialog( + viewModel = addDataPointDialogViewModel, + onDismissRequest = { finish() }) + } + } + } + setContentView(composeView) + + val bundle = intent.extras + + bundle?.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID)?.let { widgetId -> + val sharedPref = getSharedPreferences(WIDGET_PREFS_NAME, Context.MODE_PRIVATE) + val featureId = sharedPref.getLong(TrackWidgetProvider.getFeatureIdPref(widgetId), -1) + if (featureId == -1L) { + finish() + return + } + + viewModel.initFromFeatureId(featureId) + observeFeature() + } ?: finish() + } + + private fun observeFeature() = viewModel.tracker.observe(this) { tracker -> + if (tracker == null) finish() + else when (tracker.hasDefaultValue) { + true -> { + viewModel.addDefaultDataPoint() + performTrackVibrate() + finish() + } + + else -> showDialog(tracker.id) + } + } + + private fun showDialog(trackerId: Long) { + addDataPointDialogViewModel.showAddDataPointDialog(trackerId) + } + + override fun onDestroy() { + super.onDestroy() + window.hideKeyboard() + } +} + +@HiltViewModel +class TrackWidgetInputDataPointViewModel @Inject constructor( + private var dataInteractor: DataInteractor, + @IODispatcher private val io: CoroutineDispatcher, + @MainDispatcher private val ui: CoroutineDispatcher +) : ViewModel() { + + private val _tracker = MutableLiveData() + val tracker: LiveData get() = _tracker + + private var initialized = false + + fun initFromFeatureId(featureId: Long) { + if (initialized) return + initialized = true + viewModelScope.launch(io) { + val tracker = dataInteractor.getTrackerByFeatureId(featureId) + withContext(ui) { _tracker.value = tracker } + } + } + + fun addDefaultDataPoint() = tracker.value?.let { + viewModelScope.launch(io) { + val newDataPoint = DataPoint( + timestamp = OffsetDateTime.now(), + featureId = it.featureId, + value = it.defaultValue, + label = it.defaultLabel, + note = "" + ) + dataInteractor.insertDataPoint(newDataPoint) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/anim/slide_in_left.xml b/app/src/main/res/anim/slide_in_left.xml index 04747cd61..09fea3191 100644 --- a/app/src/main/res/anim/slide_in_left.xml +++ b/app/src/main/res/anim/slide_in_left.xml @@ -1,4 +1,20 @@ + + + + + android:pathData="M11,7h2v2h-2zM11,11h2v6h-2zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8z"/> diff --git a/app/src/main/res/drawable/backup_restore_icon.xml b/app/src/main/res/drawable/backup_restore_icon.xml new file mode 100644 index 000000000..8ebd3145b --- /dev/null +++ b/app/src/main/res/drawable/backup_restore_icon.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/bg_hold_to_preview.xml b/app/src/main/res/drawable/bg_hold_to_preview.xml new file mode 100644 index 000000000..6d7203a6d --- /dev/null +++ b/app/src/main/res/drawable/bg_hold_to_preview.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dash.xml b/app/src/main/res/drawable/dash.xml deleted file mode 100644 index 9583da700..000000000 --- a/app/src/main/res/drawable/dash.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/discrete_value_button.xml b/app/src/main/res/drawable/discrete_value_button.xml index 58554dc66..4dad46412 100644 --- a/app/src/main/res/drawable/discrete_value_button.xml +++ b/app/src/main/res/drawable/discrete_value_button.xml @@ -1,25 +1,25 @@ - + - + - - + + - - + + - + diff --git a/app/src/main/res/drawable/dummy_graph.xml b/app/src/main/res/drawable/dummy_graph.xml deleted file mode 100644 index b6a2205eb..000000000 --- a/app/src/main/res/drawable/dummy_graph.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/faq_icon.xml b/app/src/main/res/drawable/faq_icon.xml new file mode 100644 index 000000000..dfd39a1fd --- /dev/null +++ b/app/src/main/res/drawable/faq_icon.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/filled_circle.xml b/app/src/main/res/drawable/filled_circle.xml index 2a92440cc..2d87622ed 100644 --- a/app/src/main/res/drawable/filled_circle.xml +++ b/app/src/main/res/drawable/filled_circle.xml @@ -1,9 +1,12 @@ - - - + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/graph_nav_icon.xml b/app/src/main/res/drawable/graph_nav_icon.xml deleted file mode 100644 index c51c81a0f..000000000 --- a/app/src/main/res/drawable/graph_nav_icon.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/home_menu_icon.xml b/app/src/main/res/drawable/home_menu_icon.xml new file mode 100644 index 000000000..37a61dd0f --- /dev/null +++ b/app/src/main/res/drawable/home_menu_icon.xml @@ -0,0 +1,24 @@ + + + + diff --git a/app/src/main/res/drawable/ic_graph_example.xml b/app/src/main/res/drawable/ic_graph_example.xml new file mode 100644 index 000000000..447d2aea7 --- /dev/null +++ b/app/src/main/res/drawable/ic_graph_example.xml @@ -0,0 +1,50 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 441221263..78a5e7a0a 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -10,7 +10,7 @@ android:strokeWidth="11.99695492" android:fillColor="#74add1" android:strokeColor="#00000000" - android:fillAlpha="0.99481865" + android:fillAlpha="1" android:strokeLineCap="round"/> + + + + diff --git a/app/src/main/res/drawable/ic_scroll_grabber.xml b/app/src/main/res/drawable/ic_scroll_grabber.xml new file mode 100644 index 000000000..aa436420b --- /dev/null +++ b/app/src/main/res/drawable/ic_scroll_grabber.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/left_to_right_gradient.xml b/app/src/main/res/drawable/left_to_right_gradient.xml index 15f59f8b0..de7173e28 100644 --- a/app/src/main/res/drawable/left_to_right_gradient.xml +++ b/app/src/main/res/drawable/left_to_right_gradient.xml @@ -3,6 +3,6 @@ android:shape="rectangle" > \ No newline at end of file diff --git a/app/src/main/res/drawable/point_style_circles_and_numbers_icon.xml b/app/src/main/res/drawable/point_style_circles_and_numbers_icon.xml new file mode 100644 index 000000000..46c10e765 --- /dev/null +++ b/app/src/main/res/drawable/point_style_circles_and_numbers_icon.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/point_style_circles_icon.xml b/app/src/main/res/drawable/point_style_circles_icon.xml new file mode 100644 index 000000000..bee0b9c04 --- /dev/null +++ b/app/src/main/res/drawable/point_style_circles_icon.xml @@ -0,0 +1,36 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/point_style_none_icon.xml b/app/src/main/res/drawable/point_style_none_icon.xml new file mode 100644 index 000000000..3a1ee6020 --- /dev/null +++ b/app/src/main/res/drawable/point_style_none_icon.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/app/src/main/res/drawable/popup_tab_background.xml b/app/src/main/res/drawable/popup_tab_background.xml new file mode 100644 index 000000000..f4879a2aa --- /dev/null +++ b/app/src/main/res/drawable/popup_tab_background.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/question_mark.xml b/app/src/main/res/drawable/rate_icon.xml similarity index 58% rename from app/src/main/res/drawable/question_mark.xml rename to app/src/main/res/drawable/rate_icon.xml index dc04d3b5b..07802a0c0 100644 --- a/app/src/main/res/drawable/question_mark.xml +++ b/app/src/main/res/drawable/rate_icon.xml @@ -5,5 +5,5 @@ android:viewportHeight="24"> + android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2L9.19,8.63L2,9.24l5.46,4.73L5.82,21L12,17.27z"/> diff --git a/app/src/main/res/drawable/reminders_icon.xml b/app/src/main/res/drawable/reminders_icon.xml new file mode 100644 index 000000000..8b5869e58 --- /dev/null +++ b/app/src/main/res/drawable/reminders_icon.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/app/src/main/res/drawable/remove_icon.xml b/app/src/main/res/drawable/remove_icon.xml deleted file mode 100644 index ec9b68d79..000000000 --- a/app/src/main/res/drawable/remove_icon.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/track_nav_icon.xml b/app/src/main/res/drawable/track_nav_icon.xml deleted file mode 100644 index 7a51a771c..000000000 --- a/app/src/main/res/drawable/track_nav_icon.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/track_widget_preview.xml b/app/src/main/res/drawable/track_widget_preview.xml new file mode 100644 index 000000000..6d0c1e26c --- /dev/null +++ b/app/src/main/res/drawable/track_widget_preview.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/tutorial_image_1.jpeg b/app/src/main/res/drawable/tutorial_image_1.jpeg new file mode 100644 index 000000000..5d62c6592 Binary files /dev/null and b/app/src/main/res/drawable/tutorial_image_1.jpeg differ diff --git a/app/src/main/res/drawable/tutorial_image_2.jpeg b/app/src/main/res/drawable/tutorial_image_2.jpeg new file mode 100644 index 000000000..90db80b39 Binary files /dev/null and b/app/src/main/res/drawable/tutorial_image_2.jpeg differ diff --git a/app/src/main/res/drawable/tutorial_image_3.jpeg b/app/src/main/res/drawable/tutorial_image_3.jpeg new file mode 100644 index 000000000..aa28f0df0 Binary files /dev/null and b/app/src/main/res/drawable/tutorial_image_3.jpeg differ diff --git a/app/src/main/res/layout/about_page.xml b/app/src/main/res/layout/about_page.xml deleted file mode 100644 index 7dec4016c..000000000 --- a/app/src/main/res/layout/about_page.xml +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - - - -