diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ae01e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,89 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +.idea/androidTestResultsUserPreferences.xml +#misc.xml is annoying and useless +.idea/misc.xml +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +**/lint-report.html +# lint/reports/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..70e8ccc --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +Ivy Wallet \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..3c0ff95 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b589d56 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..ed76bea --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,37 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..e1eea1d --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..18c9147 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..aea1628 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,62 @@ +# Contributing to Ivy Wallet + +## 1. Fork the repo + +**[How To Fork Guide by GitHub](https://docs.github.com/en/get-started/quickstart/fork-a-repo)** + +`gh repo fork https://github.com/Ivy-Apps/ivy-wallet` + +## 2. Pick an issue + +What do you want to work on? How do you want to contribute? + +### Workflow: + +1. Browse **[Ivy Wallet Issues](https://github.com/Ivy-Apps/ivy-wallet/issues)**. +2. Choose a ticket that you understand and intrigues you. +3. Comment `"I'm on it"` on the ticket to let other contributors know that you're working on it. + +### Tips: + +- Issues with the + label [good first issue](https://github.com/Ivy-Apps/ivy-wallet/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) + are easier. +- You can also help us clean up the [issue section](https://github.com/Ivy-Apps/ivy-wallet/issues) by identifying duplicate issues. +- You can always make code improvements w/o having an opened issue. +- You create an issue yourself! +- Ask questions or suggest ideas in the comments section of any issue, + +## 3. Create a feature branch in your fork + +Once you've decided on what you want to contribute it's time to create a feature branch in your forked ivy-wallet +repository. + +### Console: + +`cd forked-ivy-wallet-repo-dir` + +`git checkout -b fix-issue-N` + +- Make commits. +- Refactor your code. +- Verify that your implementation works. + +### Tips: + +- Make sure that you didn't break anything with your changes. +- Use Ivy Wallet's code style. +- Keep it simple. +- **"Don't walk away from complexity, run!"** + +## 4. Submit a PR to `develop` branch + +So far you should have pushed your work to your feature branch and have tested that it works on an actual Android +device. Then final step is to open a pull request to the `develop` branch of the +official [Ivy Wallet repo.](https://github.com/Ivy-Apps/ivy-wallet/pulls) + +**[How To Submit a PR Guide by GitHub](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork)** + +### IMPORTANT: + +- Make sure that on the base repository's base the `develop` branch is chosen as "base". +- Pull requests to `main` will be rejected. diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..7a118b4 --- /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 0000000..dc5e2c4 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,214 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.5) + rexml + addressable (2.8.0) + public_suffix (>= 2.0.2, < 5.0) + artifactory (3.0.15) + atomos (0.1.3) + aws-eventstream (1.2.0) + aws-partitions (1.533.0) + aws-sdk-core (3.122.1) + aws-eventstream (~> 1, >= 1.0.2) + aws-partitions (~> 1, >= 1.525.0) + aws-sigv4 (~> 1.1) + jmespath (~> 1.0) + aws-sdk-kms (1.51.0) + aws-sdk-core (~> 3, >= 3.122.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.106.0) + aws-sdk-core (~> 3, >= 3.122.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.4) + aws-sigv4 (1.4.0) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + claide (1.0.3) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + declarative (0.0.20) + digest-crc (0.6.4) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.5.20190701) + unf (>= 0.0.5, < 1.0.0) + dotenv (2.7.6) + emoji_regex (3.2.3) + excon (0.88.0) + faraday (1.8.0) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0.1) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.1) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + multipart-post (>= 1.2, < 3) + 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-net_http (1.0.1) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday_middleware (1.2.0) + faraday (~> 1.0) + fastimage (2.2.5) + fastlane (2.198.1) + 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 + 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-storage (~> 1.31) + highline (~> 2.0) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (~> 2.0.0) + naturally (~> 2.2) + optparse (~> 0.1.1) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.3) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (>= 1.4.5, < 2.0.0) + 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) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.13.0) + google-apis-core (>= 0.4, < 2.a) + google-apis-core (0.4.1) + 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 + webrick + google-apis-iamcredentials_v1 (0.8.0) + google-apis-core (>= 0.4, < 2.a) + google-apis-playcustomapp_v1 (0.6.0) + google-apis-core (>= 0.4, < 2.a) + google-apis-storage_v1 (0.9.0) + google-apis-core (>= 0.4, < 2.a) + google-cloud-core (1.6.0) + google-cloud-env (~> 1.0) + google-cloud-errors (~> 1.0) + google-cloud-env (1.5.0) + faraday (>= 0.17.3, < 2.0) + google-cloud-errors (1.2.0) + google-cloud-storage (1.34.1) + addressable (~> 2.5) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.1) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.1.0) + faraday (>= 0.17.3, < 2.0) + jwt (>= 1.4, < 3.0) + memoist (~> 0.16) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.4) + domain_name (~> 0.5) + httpclient (2.8.3) + jmespath (1.4.0) + json (2.6.1) + jwt (2.3.0) + memoist (0.16.2) + mini_magick (4.11.0) + mini_mime (1.1.2) + multi_json (1.15.0) + multipart-post (2.0.0) + nanaimo (0.3.0) + naturally (2.2.1) + optparse (0.1.1) + os (1.1.4) + plist (3.6.0) + public_suffix (4.0.6) + rake (13.0.6) + representable (3.1.1) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.1.2) + rexml (3.2.5) + rouge (2.0.7) + ruby2_keywords (0.0.5) + rubyzip (2.3.2) + security (0.1.3) + signet (0.16.0) + addressable (~> 2.8) + faraday (>= 0.17.3, < 2.0) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simctl (1.6.8) + CFPropertyList + naturally + terminal-notifier (2.0.0) + terminal-table (1.8.0) + unicode-display_width (~> 1.1, >= 1.1.1) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.1) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unf (0.1.4) + unf_ext + unf_ext (0.0.8) + unicode-display_width (1.8.0) + webrick (1.7.0) + word_wrap (1.0.0) + xcodeproj (1.21.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 + x86_64-linux + +DEPENDENCIES + fastlane + +BUNDLED WITH + 2.2.30 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + 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 0000000..02b43e4 --- /dev/null +++ b/README.md @@ -0,0 +1,181 @@ +[![Latest Release](https://img.shields.io/github/v/release/Ivy-Apps/ivy-wallet)](https://github.com/Ivy-Apps/ivy-wallet/releases) +[![Build](https://github.com/Ivy-Apps/ivy-wallet/actions/workflows/build.yml/badge.svg)](https://github.com/Ivy-Apps/ivy-wallet/actions/workflows/build.yml) +[![Lint](https://github.com/Ivy-Apps/ivy-wallet/actions/workflows/lint.yml/badge.svg)](https://github.com/Ivy-Apps/ivy-wallet/actions/workflows/lint.yml) +[![Unit Test](https://github.com/Ivy-Apps/ivy-wallet/actions/workflows/unit_test.yml/badge.svg)](https://github.com/Ivy-Apps/ivy-wallet/actions/workflows/unit_test.yml) + + +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) +[![PRs welcome!](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/Ivy-Apps/ivy-wallet/blob/main/CONTRIBUTING.md) +[![GitHub Repo stars](https://img.shields.io/github/stars/Ivy-Apps/ivy-wallet?style=social)](https://github.com/Ivy-Apps/ivy-wallet/stargazers) +[![Github Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/Ivy-Apps) + +# [Ivy Wallet: money manager](https://play.google.com/store/apps/details?id=com.ivy.wallet) + +| | | | | +| :---: | :----: | :---: | :---: | +| ![1](https://user-images.githubusercontent.com/5564499/189540998-4d6cdcd3-ab4d-40f7-85d4-c82fe8a017d1.png) | ![2](https://user-images.githubusercontent.com/5564499/189541011-1ebbd8b6-50fe-432a-91e2-59206efe99ce.png) | ![3](https://user-images.githubusercontent.com/5564499/189541023-35e7f163-d639-4466-9a91-c56890d5a28e.png) | ![4](https://user-images.githubusercontent.com/5564499/189541027-d352314c-fd5c-43eb-82ad-4aba14c7b0fa.png) +| ![5](https://user-images.githubusercontent.com/5564499/189541030-1a0d7948-33af-420b-b126-936d0211c93f.png) | ![6](https://user-images.githubusercontent.com/5564499/189541035-621c4511-5ec7-4d3f-b08e-925d8da95472.png) |![7](https://user-images.githubusercontent.com/5564499/189541127-7adf5bfa-0652-461c-80f1-076b7179eb6c.png) | ![8](https://user-images.githubusercontent.com/5564499/189541040-7cab633e-be4c-40b2-a2c6-890a15edf805.png) + +Ivy Wallet is a **free money manager android app** written using 100% Jetpack Compose and Kotlin. It's designed to help you track your personal finance with ease. + +Imagine Ivy Wallet as a manual expense tracker that will replace the good old spreadsheet for managing your personal finance. + +Track your expenses, fast and on-the-go! ⚡ Discover powerful insights about your spending. + +**Do you know? Ask yourself.** + +1) How much money do I have right now in all accounts combined? + +2) How much did I spend this month and where? + +3) How much money can I spend and still reach my financial goals? + +A money manager app can help you answer these questions. + +Ivy Wallet's biggest advantage is its UI/UX, simplicity, and customization which was recognized in the ["Top/Best Android App in 2021/2022 charts"](https://youtube.com/playlist?list=PLguJN0waG1-eSzKMuFMIULrR3MlqJ3cAE) 10+ times by the YouTube tech community. + +Get it on Google Play + +> To support our free, open-source project please ⭐ star our repo - that means a lot for us! Thank you! [![GitHub Repo stars](https://img.shields.io/github/stars/Ivy-Apps/ivy-wallet?style=social)](https://github.com/Ivy-Apps/ivy-wallet/stargazers) + 🙏 + +> Join our **[private Telegram Community](https://t.me/+ETavgioAvWg4NThk)**. + +> You can see our future plans for the product in **[Ivy Wallet's Roadmap](https://github.com/orgs/Ivy-Apps/projects/1)**. + +> If you want to support our work see our **[GitHub Sponsors page](https://github.com/sponsors/Ivy-Apps)** [![Github Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/Ivy-Apps) + + +## Architecture +We strive to keep our architecture "perfect" by putting software-design and code quality first. + +We read a lot of books, CS papers, blogs and follow latest research in the industry. + +Our goal is to make this repo **the go-to project to learn about Android Development latest best +practices** and **software architecture.** + +### High-level view: + +- [Modular architecture](https://android-developers.googleblog.com/2022/09/announcing-new-guide-to-android-app-modularization.html) +- [FRP (Functional Reactive Programming)](https://www.toptal.com/android/functional-reactive-programming-part-1) +- [MVVM (Model-View-ViewModel)](https://www.techtarget.com/whatis/definition/Model-View-ViewModel#:~:text=Model%2DView%2DViewModel%20(MVVM)%20is%20a%20software%20design,Ken%20Cooper%20and%20John%20Gossman.) +- [Onion Architecture (FP)](https://www.codeguru.com/csharp/understanding-onion-architecture/) + +We've documented every major architecture decision as **ADR (Architecture Decision Record)** +in **[docs/architecture](docs/architecture/)**. The best thing about ADRs is that you can see not +only what went well but also what didn't! + +We're also big on Computer Science that's why we're documenting every important algorithm used in the app. To see the **algorithms** in Ivy Wallet and their detailed **space-time complexity** analysis go to **[docs/algorithms](docs/algorithms/).** + +If you're starting out with the Ivy Wallet project first have a look at our **[:core](core/)** +module. + +We're also linking **great learning materials (books, videos, articles, papers)** +in **[docs/resources 📚](docs/resources/)**. + +> Have ideas/proposals how we can make our project better? Please, get in touch! 🚀 + +[![PRs welcome!](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/Ivy-Apps/ivy-wallet/blob/main/CONTRIBUTING.md) + +## Tech Stack + +### Core + +- 100% [Kotlin](https://kotlinlang.org/) +- 100% [Jetpack Compose](https://developer.android.com/jetpack/compose) +- [Kotlin Coroutines](https://kotlinlang.org/docs/coroutines-overview.html) +- [Kotlin Flow](https://kotlinlang.org/docs/flow.html) +- [Hilt](https://dagger.dev/hilt/) (DI) +- [Jetpack Compose Navigation](https://developer.android.com/jetpack/compose/navigation) +- [ArrowKt](https://arrow-kt.io/) (Functional Programming) + +### Local Persistence +- [DataStore](https://developer.android.com/topic/libraries/architecture/datastore) (key-value storage, Shared Preferences replacement) +- [Room DB](https://developer.android.com/training/data-storage/room) (SQLite ORM) + +### Networking +- [Ktor Client](https://ktor.io/docs/getting-started-ktor-client.html) (REST client) +- [Gson](https://github.com/google/gson) (JSON serialization) + +### Other +- [Timber](https://github.com/JakeWharton/timber) (Logging) +- [Firebase Crashlytics](https://firebase.google.com/docs/crashlytics) (crashes, logging) + +### CI/CD +- [Gradle KTS](https://docs.gradle.org/current/userguide/kotlin_dsl.html) +- [Fastlane](https://fastlane.tools/) (upload to Google PlayStore) +- [Github Actions](https://github.com/Ivy-Apps/ivy-wallet/actions) (CI/CD) + + +## Project Requirements +- Java 11+ +- **Android Studio Electric Eel+** (for easy install + use [JetBrains Toolbox](https://www.jetbrains.com/toolbox-app/)) + +## How to build? +1. Clone the repository +2. Open with Android Studio +3. Everything should sync and build automatically +- _If any build problems occur, please [open a new issue](https://github.com/Ivy-Apps/ivy-wallet/issues/new?assignees=&labels=dev&template=dev-contributor-request.yml) including the logs._ + +## Ideology :earth_africa: +We believe that people _(not corporations)_ can create innovative, open-source, +and free software that can make the world a better place. + +**We want Ivy to be:** +- A place where you can excel and have fun while contributing to something meaningful. +- A community where you can express yourself freely and build the future that you want to live in. +- An open-source project with zero-tolerance to "bad" code and putting code quality above everything. + +**We believe in:** +- Freedom. +- Creativity & Innovation. +- Challenging the status quo. +- Technical excellence and **eliminating complexity at any cost**. + +> We're always open to new ideas and proposals! Have an idea? 💡 Join our [Telegram community](https://t.me/+ETavgioAvWg4NThk) or [submit us a PR](https://github.com/Ivy-Apps/ivy-wallet/blob/main/CONTRIBUTING.md) - we appreciate both! + +## Community +Be the change! Join our [Telegram community](https://t.me/+ETavgioAvWg4NThk), star our GitHub repo [![GitHub Repo stars](https://img.shields.io/github/stars/Ivy-Apps/ivy-wallet?style=social)](https://github.com/Ivy-Apps/ivy-wallet/stargazers), and +tell us how we can create a better environment for developers & creators to work together. + +### [Ivy Telegram Community](https://t.me/+ETavgioAvWg4NThk) + +## Contributors [(see graph)](https://github.com/Ivy-Apps/ivy-wallet/graphs/contributors) + +### Why to contribute? +- It's a win-win! +- You'll appear in our contributors wall. +- You can **include it in your CV/LinkedIn** and show recruiters that you contribute to open-source projects _(counts as +1 released app in the [Google PlayStore](https://play.google.com/store/apps/details?id=com.ivy.wallet))_. +- You'll make Ivy Wallet better. +- You can develop the features that you miss in the app yourself, the way you want them. +- You'll play around and learn cutting-edge technologies. +- It's the easiest way to learn [Jetpack Compose](https://developer.android.com/jetpack/compose) in + a production environment. +- You can see Android Development Best Practices in 2022 (and also help us improve our code). + +### How to contribute? + +Follow our +compact **[Contributors Guide](https://github.com/Ivy-Apps/ivy-wallet/blob/main/CONTRIBUTING.md)** +to begin. + +**TL;DR:** +- Submit pull requests for bug fixes, code improvements and features to the `develop` branch. +- Implement and submit PRs for opened issues. +- Report (or fix) bugs/glitches. +- Create new issues to give us ideas and feedback. +- [Download Ivy Wallet](https://play.google.com/store/apps/details?id=com.ivy.wallet) and leave us a review ⭐⭐⭐⭐⭐. +- Star our GitHub repo [![GitHub Repo stars](https://img.shields.io/github/stars/Ivy-Apps/ivy-wallet?style=social)](https://github.com/Ivy-Apps/ivy-wallet). +- Fix typos in READMEs (.md files), Docs and broken links - we have a lot of them as you'll see and fixing them helps a lot, too! + +I hope a lot more profile pictures are going to show up here, soon! + +### Contributors Wall: + + + +
+
+ +_Note: It may take up to 24h for the [contrib.rocks](https://contrib.rocks/preview?repo=Ivy-Apps%2Fivy-wallet) plugin to update because it's refreshed once a day._ diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..a2c8814 --- /dev/null +++ b/android/README.md @@ -0,0 +1,6 @@ +# Android + +Contains Android SDK specific code like: +- `android:notifications` - creating notification channels and showing notifications +- `android:billing` - Google Play Billing SDK +- `android:file-system` - CRUD files on user's phone and handle permissions \ No newline at end of file diff --git a/android/billing/.gitignore b/android/billing/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/android/billing/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/billing/README.md b/android/billing/README.md new file mode 100644 index 0000000..c5b1970 --- /dev/null +++ b/android/billing/README.md @@ -0,0 +1,3 @@ +# Billing + +Implement Ivy Wallet plans using the [Google Play Billing SDK](https://developer.android.com/google/play/billing). \ No newline at end of file diff --git a/android/billing/build.gradle.kts b/android/billing/build.gradle.kts new file mode 100644 index 0000000..ef5af5b --- /dev/null +++ b/android/billing/build.gradle.kts @@ -0,0 +1,19 @@ +import com.ivy.buildsrc.Billing +import com.ivy.buildsrc.Hilt + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + + implementation(project(":core:data-model")) + implementation(project(":core:ui")) + + Billing(api = true) +} \ No newline at end of file diff --git a/android/billing/src/main/AndroidManifest.xml b/android/billing/src/main/AndroidManifest.xml new file mode 100644 index 0000000..93f0631 --- /dev/null +++ b/android/billing/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/android/billing/src/main/java/com/ivy/billing/IvyBilling.kt b/android/billing/src/main/java/com/ivy/billing/IvyBilling.kt new file mode 100644 index 0000000..813ffb3 --- /dev/null +++ b/android/billing/src/main/java/com/ivy/billing/IvyBilling.kt @@ -0,0 +1,203 @@ +package com.ivy.billing + +import com.android.billingclient.api.BillingClient + +class IvyBilling( + +) { + companion object { + private const val MONTHLY_V1 = "monthly_v1" + private const val SIX_MONTH_V1 = "six_month_v1" + private const val YEARLY_V1 = "yearly_v1" + + private const val LIFETIME_V1 = "ivy_wallet_lifetime_v1" + + const val DONATE_2 = "donate_2" + const val DONATE_5 = "donate_5" + const val DONATE_10 = "donate_10" + const val DONATE_15 = "donate_15" + const val DONATE_25 = "donate_25" + const val DONATE_50 = "donate_50" + const val DONATE_100 = "donate_100" + + val SUBSCRIPTIONS = listOf( + MONTHLY_V1, + SIX_MONTH_V1, + YEARLY_V1, + ) + + val ONE_TIME_PLANS = listOf( + DONATE_2, + DONATE_5, + DONATE_10, + DONATE_15, + DONATE_25, + DONATE_50, + DONATE_100 + ) + } + + private lateinit var billingClient: BillingClient +// +// fun init( +// activity: Activity, +// onReady: () -> Unit, +// onPurchases: (List) -> Unit, +// onError: (code: Int, msg: String) -> Unit, +// ) { +// val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases -> +// if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { +// onPurchases(purchases) +// } else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) { +// onError(billingResult.responseCode, billingResult.debugMessage) +// } else { +// onError(billingResult.responseCode, billingResult.debugMessage) +// } +// +// } +// +// billingClient = BillingClient.newBuilder(activity) +// .setListener(purchasesUpdatedListener) +// .enablePendingPurchases() +// .build() +// +// billingClient.startConnection(object : BillingClientStateListener { +// override fun onBillingSetupFinished(billingResult: BillingResult) { +// if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { +// // The BillingClient is ready. You can query purchases here. +// onReady() +// } else { +// onError(billingResult.responseCode, billingResult.debugMessage) +// } +// } +// +// override fun onBillingServiceDisconnected() { +// // Try to restart the connection on the next request to +// // Google Play by calling the startConnection() method. +// onError(-666, "onBillingServiceDisconnected") +// } +// }) +// } +// +// suspend fun queryPurchases(): List { +// return ioThread { +// try { +// queryBoughtSubscriptions() +// .plus(queryBoughtOneTimeOffers()) +// } catch (e: Exception) { +// e.printStackTrace() +// emptyList() +// } +// } +// } +// +// private suspend fun queryBoughtSubscriptions(): List { +// return billingClient.queryPurchasesAsync(BillingClient.SkuType.SUBS).purchasesList +// } +// +// private suspend fun queryBoughtOneTimeOffers(): List { +// return try { +// billingClient.queryPurchasesAsync(BillingClient.SkuType.INAPP).purchasesList +// } catch (e: Exception) { +// e.printStackTrace() +// emptyList() +// } +// } +// +// suspend fun fetchPlans(): List { +// return fetchSubscriptions().plus(fetchOneTimePlans()) +// } +// +// private suspend fun fetchSubscriptions(): List { +// val params = SkuDetailsParams.newBuilder() +// .setSkusList(SUBSCRIPTIONS) +// .setType(BillingClient.SkuType.SUBS) +// +// // leverage querySkuDetails Kotlin extension function +// val skuDetailsResult = ioThread { +// billingClient.querySkuDetails(params.build()) +// } +// +// return skuDetailsResult.skuDetailsList +// .orEmpty() +// .map { +// val type = when (it.subscriptionPeriod) { +// "P1M" -> PlanType.MONTHLY +// "P6M" -> PlanType.SIX_MONTH +// "P1Y" -> PlanType.YEARLY +// else -> return@map null +// } +// Plan( +// sku = it.sku, +// type = type, +// price = it.price, +// skuDetails = it +// ) +// } +// .filterNotNull() +// } +// +// suspend fun fetchOneTimePlans(): List { +// val params = SkuDetailsParams.newBuilder() +// .setSkusList(ONE_TIME_PLANS) +// .setType(BillingClient.SkuType.INAPP) +// +// // leverage querySkuDetails Kotlin extension function +// val skuDetailsResult = ioThread { +// billingClient.querySkuDetails(params.build()) +// } +// +// return skuDetailsResult.skuDetailsList +// .orEmpty() +// .map { +// Plan( +// sku = it.sku, +// type = PlanType.LIFETIME, +// price = it.price, +// skuDetails = it +// ) +// } +// } +// +// fun buy( +// activity: Activity, +// skuToBuy: SkuDetails, +// oldSubscriptionPurchaseToken: String? +// ) { +// val flowBuilder = BillingFlowParams.newBuilder() +// .setSkuDetails(skuToBuy) +// +// if (oldSubscriptionPurchaseToken != null && oldSubscriptionPurchaseToken.isNotBlank()) { +// flowBuilder.setSubscriptionUpdateParams( +// BillingFlowParams.SubscriptionUpdateParams +// .newBuilder() +// .setOldSkuPurchaseToken(oldSubscriptionPurchaseToken) +// .build() +// ) +// } +// +// val billingResult = billingClient.launchBillingFlow(activity, flowBuilder.build()) +// Timber.i("buy(): code=${billingResult.responseCode}, msg: ${billingResult.debugMessage}") +// } +// +// suspend fun checkPremium( +// purchase: Purchase, +// onActivatePremium: (Purchase) -> Unit +// ) { +// if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { +// // Grant entitlement to the user. +// onActivatePremium(purchase) +// +// if (!purchase.isAcknowledged) { +// val acknowledgeResult = ioThread { +// billingClient.acknowledgePurchase( +// AcknowledgePurchaseParams.newBuilder() +// .setPurchaseToken(purchase.purchaseToken) +// .build() +// ) +// } +// Timber.i("Acknowledge purchase result, code=${acknowledgeResult.responseCode}: ${acknowledgeResult.debugMessage}") +// } +// } +// } +} \ No newline at end of file diff --git a/android/billing/src/main/java/com/ivy/billing/Plan.kt b/android/billing/src/main/java/com/ivy/billing/Plan.kt new file mode 100644 index 0000000..4f6af6e --- /dev/null +++ b/android/billing/src/main/java/com/ivy/billing/Plan.kt @@ -0,0 +1,44 @@ +package com.ivy.billing + +import com.android.billingclient.api.SkuDetails + +data class Plan( + val sku: String, + val type: PlanType, + val price: String, + val skuDetails: SkuDetails +) { + + fun parsePrice(): AmountCurrency? { + try { + val currency = price.take(3) + val amount = price + .removeRange(0, 4) + .replace(",", "") + .replace(" ", "") + .toDoubleOrNull() ?: return null + + return AmountCurrency( + amount = amount, + currency = currency + ) + } catch (e: Exception) { + return null + } + + } + + fun freePeriod(): String = when (skuDetails.freeTrialPeriod) { + "P3D" -> "3-days for free" + "P1W" -> "7-days for free" + "P7D" -> "7-days for free" + "P2W" -> "14-days for free" + "P14D" -> "14-days for free" + else -> "for free" + } + + data class AmountCurrency( + val amount: Double, + val currency: String + ) +} \ No newline at end of file diff --git a/android/billing/src/main/java/com/ivy/billing/PlanType.kt b/android/billing/src/main/java/com/ivy/billing/PlanType.kt new file mode 100644 index 0000000..2a5d136 --- /dev/null +++ b/android/billing/src/main/java/com/ivy/billing/PlanType.kt @@ -0,0 +1,5 @@ +package com.ivy.billing + +enum class PlanType { + MONTHLY, SIX_MONTH, YEARLY, LIFETIME +} \ No newline at end of file diff --git a/android/common/.gitignore b/android/common/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/android/common/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/common/README.md b/android/common/README.md new file mode 100644 index 0000000..1aa0968 --- /dev/null +++ b/android/common/README.md @@ -0,0 +1,3 @@ +# Android Base + +Common Android \ No newline at end of file diff --git a/android/common/build.gradle.kts b/android/common/build.gradle.kts new file mode 100644 index 0000000..484de8b --- /dev/null +++ b/android/common/build.gradle.kts @@ -0,0 +1,16 @@ +import com.ivy.buildsrc.AppCompat +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + AppCompat(api = true) + Testing() +} \ No newline at end of file diff --git a/android/common/src/main/AndroidManifest.xml b/android/common/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a6e254b --- /dev/null +++ b/android/common/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/android/common/src/main/java/com/ivy/android/common/ActivityLauncher.kt b/android/common/src/main/java/com/ivy/android/common/ActivityLauncher.kt new file mode 100644 index 0000000..0da2e4f --- /dev/null +++ b/android/common/src/main/java/com/ivy/android/common/ActivityLauncher.kt @@ -0,0 +1,29 @@ +package com.ivy.android.common + +import android.content.Context +import android.content.Intent +import androidx.activity.result.ActivityResultLauncher +import androidx.appcompat.app.AppCompatActivity + +abstract class ActivityLauncher { + private lateinit var launcher: ActivityResultLauncher + private lateinit var internalCallback: (resultCode: Int, data: Intent?) -> Unit + + protected abstract fun intent(context: Context, input: Input): Intent + protected abstract fun onActivityResult(resultCode: Int, intent: Intent?): Output + + fun wire(activity: AppCompatActivity) { + launcher = activity.activityForResultLauncher( + createIntent = ::intent + ) { resultCode, intent -> + internalCallback(resultCode, intent) + } + } + + fun launch(input: Input, onResult: (Output) -> Unit) { + internalCallback = { resultCode, intent -> + onResult(onActivityResult(resultCode, intent)) + } + launcher.launch(input) + } +} \ No newline at end of file diff --git a/android/common/src/main/java/com/ivy/android/common/ActivityResultExt.kt b/android/common/src/main/java/com/ivy/android/common/ActivityResultExt.kt new file mode 100644 index 0000000..54debce --- /dev/null +++ b/android/common/src/main/java/com/ivy/android/common/ActivityResultExt.kt @@ -0,0 +1,75 @@ +package com.ivy.android.common + +import android.content.Context +import android.content.Intent +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContract +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment + +fun AppCompatActivity.simpleActivityForResultLauncher( + intent: Intent, + onActivityResult: (resultCode: Int, data: Intent?) -> Unit +): ActivityResultLauncher { + return activityForResultLauncher( + createIntent = { _, _ -> intent }, + onActivityResult = onActivityResult + ) +} + +fun Fragment.simpleActivityForResultLauncher( + intent: Intent, + onActivityResult: (resultCode: Int, data: Intent?) -> Unit +): ActivityResultLauncher { + return activityForResultLauncher( + createIntent = { _, _ -> intent }, + onActivityResult = onActivityResult + ) +} + +fun AppCompatActivity.activityForResultLauncher( + createIntent: (context: Context, input: I) -> Intent, + onActivityResult: (resultCode: Int, data: Intent?) -> Unit +): ActivityResultLauncher { + return registerForActivityResult( + activityResultContract( + createIntent = createIntent, + onActivityResult = onActivityResult + ) + ) { + } +} + +fun Fragment.activityForResultLauncher( + createIntent: (context: Context, input: I) -> Intent, + onActivityResult: (resultCode: Int, data: Intent?) -> Unit +): ActivityResultLauncher { + return registerForActivityResult( + activityResultContract( + createIntent = createIntent, + onActivityResult = onActivityResult + ) + ) { + } +} + +private fun activityResultContract( + createIntent: (context: Context, input: I) -> Intent, + onActivityResult: (resultCode: Int, data: Intent?) -> Unit +): ActivityResultContract { + return object : ActivityResultContract() { + override fun createIntent( + context: Context, + input: I + ): Intent { + return createIntent(context, input) + } + + override fun parseResult( + resultCode: Int, + intent: Intent? + ) { + onActivityResult(resultCode, intent) + } + } +} \ No newline at end of file diff --git a/android/file-system/.gitignore b/android/file-system/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/android/file-system/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/file-system/README.md b/android/file-system/README.md new file mode 100644 index 0000000..93206cd --- /dev/null +++ b/android/file-system/README.md @@ -0,0 +1,5 @@ +# File system + +Handles Android's file system specifics like read/write/delete a file from Uri, permissions and etc. + +It also exposes a few handy other file utils like zipping. \ No newline at end of file diff --git a/android/file-system/build.gradle.kts b/android/file-system/build.gradle.kts new file mode 100644 index 0000000..c580e77 --- /dev/null +++ b/android/file-system/build.gradle.kts @@ -0,0 +1,16 @@ +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":android:common")) + Testing() +} \ No newline at end of file diff --git a/android/file-system/src/main/AndroidManifest.xml b/android/file-system/src/main/AndroidManifest.xml new file mode 100644 index 0000000..76b67d8 --- /dev/null +++ b/android/file-system/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/android/file-system/src/main/java/com/ivy/file/CreateFileLauncher.kt b/android/file-system/src/main/java/com/ivy/file/CreateFileLauncher.kt new file mode 100644 index 0000000..8117a05 --- /dev/null +++ b/android/file-system/src/main/java/com/ivy/file/CreateFileLauncher.kt @@ -0,0 +1,31 @@ +package com.ivy.file + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Environment +import android.provider.DocumentsContract +import com.ivy.android.common.ActivityLauncher +import javax.inject.Inject + +typealias FileName = String + +class CreateFileLauncher @Inject constructor() : ActivityLauncher() { + override fun intent(context: Context, input: FileName): Intent { + return Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/csv" + putExtra(Intent.EXTRA_TITLE, input) + + // Optionally, specify a URI for the directory that should be opened in + // the system file picker before your app creates the document. + putExtra( + DocumentsContract.EXTRA_INITIAL_URI, + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + .toURI() + ) + } + } + + override fun onActivityResult(resultCode: Int, intent: Intent?): Uri? = intent?.data +} \ No newline at end of file diff --git a/android/file-system/src/main/java/com/ivy/file/FDMode.kt b/android/file-system/src/main/java/com/ivy/file/FDMode.kt new file mode 100644 index 0000000..fbd1b8a --- /dev/null +++ b/android/file-system/src/main/java/com/ivy/file/FDMode.kt @@ -0,0 +1,6 @@ +package com.ivy.file + +enum class FDMode(val value: String) { + Read("r"), + Write("w") +} \ No newline at end of file diff --git a/android/file-system/src/main/java/com/ivy/file/FilePickerLauncher.kt b/android/file-system/src/main/java/com/ivy/file/FilePickerLauncher.kt new file mode 100644 index 0000000..21e7def --- /dev/null +++ b/android/file-system/src/main/java/com/ivy/file/FilePickerLauncher.kt @@ -0,0 +1,24 @@ +package com.ivy.file + +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.ivy.android.common.ActivityLauncher +import com.ivy.data.file.FileType +import javax.inject.Inject + +class FilePickerLauncher @Inject constructor( +) : ActivityLauncher() { + override fun intent(context: Context, input: FileType): Intent = Intent( + Intent.ACTION_OPEN_DOCUMENT + ).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = when (input) { + FileType.Everything -> "*/*" + FileType.Zip -> "application/zip" + FileType.CSV -> "application/csv" + } + } + + override fun onActivityResult(resultCode: Int, intent: Intent?): Uri? = intent?.data +} diff --git a/android/file-system/src/main/java/com/ivy/file/FileUtil.kt b/android/file-system/src/main/java/com/ivy/file/FileUtil.kt new file mode 100644 index 0000000..320fada --- /dev/null +++ b/android/file-system/src/main/java/com/ivy/file/FileUtil.kt @@ -0,0 +1,131 @@ +package com.ivy.file + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import arrow.core.Either +import java.io.* +import java.nio.charset.Charset + +// TODO: Refactor and re-work! It's a fine mess... + +fun writeToFile(context: Context, uri: Uri, content: String) { + try { + val contentResolver = context.contentResolver + + contentResolver.openFileDescriptor(uri, FDMode.Write.value)?.use { + FileOutputStream(it.fileDescriptor).use { fOut -> + val writer = fOut.writer(charset = Charsets.UTF_16) + writer.write(content) + writer.close() + } + } + } catch (e: FileNotFoundException) { + e.printStackTrace() + } catch (e: IOException) { + e.printStackTrace() + } +} + +fun writeToFileUnsafe(context: Context, uri: Uri, content: ByteArray) { + val contentResolver = context.contentResolver + + contentResolver.openFileDescriptor(uri, FDMode.Write.value)?.use { + FileOutputStream(it.fileDescriptor).use { fOut -> + fOut.write(content) + } + } +} + +fun readFileAsBytes( + context: Context, + uri: Uri, +): ByteArray? { + return try { + val contentResolver = context.contentResolver + var fileContent: ByteArray? = null + contentResolver.openFileDescriptor(uri, FDMode.Read.value)?.use { + FileInputStream(it.fileDescriptor).use { fileInputStream -> + fileContent = fileInputStream.readBytes() + } + } + fileContent + } catch (e: FileNotFoundException) { + e.printStackTrace() + null + } catch (e: IOException) { + e.printStackTrace() + null + } +} + + +fun readFile( + context: Context, + uri: Uri, + charset: Charset +): String? { + return try { + val contentResolver = context.contentResolver + + var fileContent: String? = null + + contentResolver.openFileDescriptor(uri, FDMode.Read.value)?.use { + FileInputStream(it.fileDescriptor).use { fileInputStream -> + fileContent = readFileContent( + fileInputStream = fileInputStream, + charset = charset + ) + } + } + + fileContent + } catch (e: FileNotFoundException) { + e.printStackTrace() + null + } catch (e: IOException) { + e.printStackTrace() + null + } +} + +fun inputStream( + context: Context, + uri: Uri, + mode: FDMode, + use: (InputStream) -> T +): Either = Either.catch({ it }) { + val contentResolver = context.contentResolver + contentResolver.openFileDescriptor(uri, mode.value)?.use { + use(FileInputStream(it.fileDescriptor)) + } ?: error("contentResolver.openFileDescriptor($uri, $mode) returned null") +} + +@Throws(IOException::class) +private fun readFileContent( + fileInputStream: FileInputStream, + charset: Charset +): String { + BufferedReader(InputStreamReader(fileInputStream, charset)).use { br -> + val sb = StringBuilder() + var line: String? + while (br.readLine().also { line = it } != null) { + sb.append(line) + sb.append('\n') + } + return sb.toString() + } +} + +fun Context.getFileName(uri: Uri): String? = when (uri.scheme) { + ContentResolver.SCHEME_CONTENT -> getContentFileName(uri) + else -> uri.path?.let(::File)?.name +} + +private fun Context.getContentFileName(uri: Uri): String? = runCatching { + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + cursor.moveToFirst() + return@use cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME).let(cursor::getString) + } +}.getOrNull() \ No newline at end of file diff --git a/android/file-system/src/main/java/com/ivy/file/ZipUtil.kt b/android/file-system/src/main/java/com/ivy/file/ZipUtil.kt new file mode 100644 index 0000000..87aa947 --- /dev/null +++ b/android/file-system/src/main/java/com/ivy/file/ZipUtil.kt @@ -0,0 +1,98 @@ +package com.ivy.file + +import android.content.Context +import android.net.Uri +import java.io.* +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +private const val MODE_WRITE = "w" +private const val MODE_READ = "r" + +fun zip(zipFile: File, files: List) { + ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { outStream -> + zip(outStream, files) + } +} + +fun zip(context: Context, zipFile: Uri, files: List) { + context.contentResolver.openFileDescriptor(zipFile, MODE_WRITE).use { descriptor -> + descriptor?.fileDescriptor?.let { + ZipOutputStream(BufferedOutputStream(FileOutputStream(it))).use { outStream -> + zip(outStream, files) + } + } + } +} + +private fun zip( + outStream: ZipOutputStream, + files: List, + includeParentFolder: Boolean = false +) { + files.forEach { file -> + if (file.isDirectory) { + file.mkdir() + zip(outStream, file.listFiles()?.toList() ?: emptyList(), includeParentFolder = true) + } else { + val fileLoc: String = + if (file.parent.isNullOrEmpty() || !includeParentFolder) file.name else (file.parent!!).substring( + file.parent!!.lastIndexOf("/") + ) + "/" + file.name + + outStream.putNextEntry(ZipEntry(fileLoc)) + BufferedInputStream(FileInputStream(file)).use { inStream -> + inStream.copyTo(outStream) + } + } + + } +} + +fun unzip(zipFile: File, location: File) { + ZipInputStream(BufferedInputStream(FileInputStream(zipFile))).use { inStream -> + unzip(inStream, location) + } +} + +fun unzip(context: Context, zipFilePath: Uri, unzipLocation: File) { + context.contentResolver.openFileDescriptor(zipFilePath, MODE_READ).use { descriptor -> + descriptor?.fileDescriptor?.let { + ZipInputStream(BufferedInputStream(FileInputStream(it))).use { inStream -> + unzip(inStream, unzipLocation) + } + } + } +} + +private fun unzip(inStream: ZipInputStream, location: File) { + if (location.exists() && !location.isDirectory) + throw IllegalStateException("Location file must be directory or not exist") + + if (!location.isDirectory) location.mkdirs() + + val locationPath = location.absolutePath.let { + if (!it.endsWith(File.separator)) "$it${File.separator}" + else it + } + + var zipEntry: ZipEntry? + var unzipFile: File + var unzipParentDir: File? + + while (inStream.nextEntry.also { zipEntry = it } != null) { + unzipFile = File(locationPath + zipEntry!!.name) + if (zipEntry!!.isDirectory) { + if (!unzipFile.isDirectory) unzipFile.mkdirs() + } else { + unzipParentDir = unzipFile.parentFile + if (unzipParentDir != null && !unzipParentDir.isDirectory) { + unzipParentDir.mkdirs() + } + BufferedOutputStream(FileOutputStream(unzipFile)).use { outStream -> + inStream.copyTo(outStream) + } + } + } +} \ No newline at end of file diff --git a/android/notifications/.gitignore b/android/notifications/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/android/notifications/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/notifications/README.md b/android/notifications/README.md new file mode 100644 index 0000000..00825b4 --- /dev/null +++ b/android/notifications/README.md @@ -0,0 +1,3 @@ +# Notifications + +Handles the managing of Ivy Wallet's notifications and their Android specifics like notification channels, permissions, etc. \ No newline at end of file diff --git a/android/notifications/build.gradle.kts b/android/notifications/build.gradle.kts new file mode 100644 index 0000000..b39da6d --- /dev/null +++ b/android/notifications/build.gradle.kts @@ -0,0 +1,17 @@ +import com.ivy.buildsrc.AndroidX +import com.ivy.buildsrc.Hilt + + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":core:ui")) + AndroidX(api = false) +} \ No newline at end of file diff --git a/android/notifications/src/main/AndroidManifest.xml b/android/notifications/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a989521 --- /dev/null +++ b/android/notifications/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/android/notifications/src/main/java/com/ivy/notifications/IvyNotification.kt b/android/notifications/src/main/java/com/ivy/notifications/IvyNotification.kt new file mode 100644 index 0000000..1a9808f --- /dev/null +++ b/android/notifications/src/main/java/com/ivy/notifications/IvyNotification.kt @@ -0,0 +1,9 @@ +package com.ivy.notifications + +import android.content.Context +import androidx.core.app.NotificationCompat + +class IvyNotification( + context: Context, + val ivyChannel: IvyNotificationChannel +) : NotificationCompat.Builder(context, ivyChannel.channelId) \ No newline at end of file diff --git a/android/notifications/src/main/java/com/ivy/notifications/IvyNotificationChannel.kt b/android/notifications/src/main/java/com/ivy/notifications/IvyNotificationChannel.kt new file mode 100644 index 0000000..7a8c36d --- /dev/null +++ b/android/notifications/src/main/java/com/ivy/notifications/IvyNotificationChannel.kt @@ -0,0 +1,42 @@ +package com.ivy.notifications + +import android.annotation.SuppressLint +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import androidx.core.content.ContextCompat + +enum class IvyNotificationChannel( + val channelId: String, + val channelName: String, + val description: String, + val importance: Int = NotificationManager.IMPORTANCE_MAX, + val bypassDnd: Boolean = true +) { + TRANSACTION_REMINDER( + channelId = "transaction_reminder", + channelName = "Transaction reminder", + description = "Reminding you to record your transactions on a daily basis.", + importance = NotificationManager.IMPORTANCE_HIGH, + bypassDnd = false + ); + + + @SuppressLint("WrongConstant") + fun create(context: Context): NotificationChannel { + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + val colorPurple = ContextCompat.getColor(context, R.color.ivy) + val channel = NotificationChannel( + channelId, + channelName, + importance + ) + channel.description = description + channel.lightColor = colorPurple + channel.enableLights(true) + channel.enableVibration(true) + channel.setBypassDnd(false) + return channel + } +} \ No newline at end of file diff --git a/android/notifications/src/main/java/com/ivy/notifications/NotificationService.kt b/android/notifications/src/main/java/com/ivy/notifications/NotificationService.kt new file mode 100644 index 0000000..7ee7fbf --- /dev/null +++ b/android/notifications/src/main/java/com/ivy/notifications/NotificationService.kt @@ -0,0 +1,51 @@ +package com.ivy.notifications + +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.ivy.resources.R + +class NotificationService( + private val context: Context +) { + + fun defaultIvyNotification( + channel: IvyNotificationChannel, + autoCancel: Boolean = true, + priority: Int = NotificationCompat.PRIORITY_HIGH + ): IvyNotification { + val ivyNotification = IvyNotification(context, channel) + val color = ContextCompat.getColor(context, R.color.green) + ivyNotification.setSmallIcon(R.drawable.ic_notification) + .setColor(color) + .setPriority(priority) + .setColorized(true) + .setLights(color, 1000, 200) + .setAutoCancel(autoCancel) + return ivyNotification + } + + + fun showNotification( + notification: NotificationCompat.Builder, + notificationId: Int + ) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + ?: return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Register the channel with the system + val channel = (notification as IvyNotification).ivyChannel.create(context) + notificationManager.createNotificationChannel(channel) + } + notificationManager.notify(notificationId, notification.build()) + } + + fun dismissNotification(notificationId: Int) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(notificationId) + } +} \ No newline at end of file diff --git a/app-locked/.gitignore b/app-locked/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app-locked/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app-locked/README.md b/app-locked/README.md new file mode 100644 index 0000000..46c2234 --- /dev/null +++ b/app-locked/README.md @@ -0,0 +1,3 @@ +# App lock + +The lock-screen of Ivy Wallet which you see and you've turned ON the "lock app" setting. \ No newline at end of file diff --git a/app-locked/build.gradle.kts b/app-locked/build.gradle.kts new file mode 100644 index 0000000..e96030d --- /dev/null +++ b/app-locked/build.gradle.kts @@ -0,0 +1,17 @@ +import com.ivy.buildsrc.Hilt + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + + implementation(project(":design-system")) + implementation(project(":core:ui")) + implementation(project(":core:data-model")) +} \ No newline at end of file diff --git a/app-locked/src/main/AndroidManifest.xml b/app-locked/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4b79c7a --- /dev/null +++ b/app-locked/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app-locked/src/main/java/com/ivy/locked/AppLockedScreen.kt b/app-locked/src/main/java/com/ivy/locked/AppLockedScreen.kt new file mode 100644 index 0000000..b7d35c7 --- /dev/null +++ b/app-locked/src/main/java/com/ivy/locked/AppLockedScreen.kt @@ -0,0 +1,133 @@ +package com.ivy.locked + + +import android.app.KeyguardManager +import android.content.Context +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +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.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.Gray +import com.ivy.design.l0_system.style +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview + +@Composable +fun BoxWithConstraintsScope.AppLockedScreen( + onShowOSBiometricsModal: () -> Unit, + onContinueWithoutAuthentication: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(Modifier.height(32.dp)) + + Text( + modifier = Modifier + .background(UI.colors.medium, UI.shapes.fullyRounded) + .padding(vertical = 12.dp) + .padding(horizontal = 32.dp), + text = stringResource(R.string.app_locked), + style = UI.typo.b2.style( + fontWeight = FontWeight.ExtraBold, + ) + ) + + Spacer(Modifier.weight(1f)) + + Image( + modifier = Modifier + .size(width = 96.dp, height = 138.dp), + painter = painterResource(id = R.drawable.ic_fingerprint), + colorFilter = ColorFilter.tint(UI.colors.medium), + contentScale = ContentScale.FillBounds, + contentDescription = "unlock icon" + ) + + Spacer(Modifier.weight(1f)) + + Text( + text = stringResource(R.string.authenticate_text), + style = UI.typo.b2.style( + fontWeight = FontWeight.SemiBold, + color = Gray + ) + ) + + Spacer(Modifier.height(24.dp)) + + val context = LocalContext.current + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Focused, + feeling = Feeling.Positive, + text = stringResource(R.string.unlock), + icon = null, + ) { + osAuthentication( + context = context, + onShowOSBiometricsModal = onShowOSBiometricsModal, + onContinueWithoutAuthentication = onContinueWithoutAuthentication + ) + } + + Spacer(Modifier.height(24.dp)) + + //To automatically launch the biometric screen on load of this composable + LaunchedEffect(true) { + osAuthentication( + context = context, + onShowOSBiometricsModal = onShowOSBiometricsModal, + onContinueWithoutAuthentication = onContinueWithoutAuthentication + ) + } + } +} + +private fun osAuthentication( + context: Context, + onShowOSBiometricsModal: () -> Unit, + onContinueWithoutAuthentication: () -> Unit +) { + if (hasLockScreen(context)) { + onShowOSBiometricsModal() + } else { + onContinueWithoutAuthentication() + } +} + +private fun hasLockScreen(context: Context): Boolean { + val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + return keyguardManager.isDeviceSecure +} + +@Preview +@Composable +private fun Preview_Locked() { + IvyPreview { + AppLockedScreen( + onContinueWithoutAuthentication = {}, + onShowOSBiometricsModal = {} + ) + } +} diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..cd7c39f --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,2 @@ +/build +/lint-merged-report.html diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..edd4e6c --- /dev/null +++ b/app/README.md @@ -0,0 +1,9 @@ +# App + +Root's application module which includes all other modules. A behemoth that compiles as the Ivy Wallet app you see on your device. + +**Key responsibilities** +- `RootActivity` - applicaiton's single activity +- `RootScreen` impl, exposes activity related stuff to other modules +- Hosts Jetpack Navigation graph +- `IvyWalletApp` \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..1d52d46 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,183 @@ +import com.ivy.buildsrc.* + +plugins { + id("com.android.application") + id("kotlin-android") + id("kotlin-kapt") + id("com.google.gms.google-services") + id("com.google.firebase.crashlytics") + id("org.jetbrains.kotlin.android") + id("dagger.hilt.android.plugin") + id("io.kotest") +} + +android { + compileSdk = com.ivy.buildsrc.Project.compileSdkVersion + + defaultConfig { + applicationId = com.ivy.buildsrc.Project.applicationId + minSdk = com.ivy.buildsrc.Project.minSdk + targetSdk = com.ivy.buildsrc.Project.targetSdk + versionCode = com.ivy.buildsrc.Project.versionCode + versionName = com.ivy.buildsrc.Project.versionName + + testInstrumentationRunner = "com.ivy.wallet.IvyAppTestRunner" + + kapt { + arguments { + arg("room.schemaLocation", "$projectDir/schemas") + } + + correctErrorTypes = true + } + } + + signingConfigs { + getByName("debug") { + storeFile = file("../debug.jks") + storePassword = "IVY7834!DEbug" + keyAlias = "debug" + keyPassword = "IVY7834!DEbug" + } + + create("release") { + storeFile = file("../sign.jks") + storePassword = System.getenv("SIGNING_STORE_PASSWORD") + keyAlias = System.getenv("SIGNING_KEY_ALIAS") + keyPassword = System.getenv("SIGNING_KEY_PASSWORD") + } + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + isDebuggable = false + isDefault = false + + signingConfig = signingConfigs.getByName("release") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + + resValue("string", "app_name", "Ivy Wallet") + } + + create("demo") { + isMinifyEnabled = true + isShrinkResources = true + isDebuggable = false + isDefault = false + + signingConfig = signingConfigs.getByName("debug") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + + applicationIdSuffix = ".debug" + matchingFallbacks.add("release") + resValue("string", "app_name", "Ivy Wallet Demo") + } + + debug { + isDebuggable = true + isMinifyEnabled = false + isDefault = true + + signingConfig = signingConfigs.getByName("debug") + + applicationIdSuffix = ".debug" + resValue("string", "app_name", "Ivy Wallet Debug") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + freeCompilerArgs = freeCompilerArgs + listOf("-Xskip-prerelease-check") + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = com.ivy.buildsrc.Versions.composeCompilerVersion + } + + lint { +// isCheckReleaseBuilds = true +// isAbortOnError = false + checkDependencies = true + xmlReport = false + htmlReport = true + htmlOutput = File(projectDir, "lint-merged-report.html") + } + + packagingOptions { + //Exclude this files so Jetpack Compose UI tests can build + resources.excludes.add("META-INF/AL2.0") + resources.excludes.add("META-INF/LGPL2.1") + resources.excludes.add("META-INF/DEPENDENCIES") + //------------------------------------------------------- + } + + hilt { + enableExperimentalClasspathAggregation = true + } + + testOptions { + unitTests.all { + //Required by Kotest + it.useJUnitPlatform() + } + } +} + +dependencies { + implementation(project(":common:main")) + implementation(project(":design-system")) + implementation(project(":core:ui")) + implementation(project(":navigation")) + implementation(project(":categories")) + implementation(project(":settings")) + implementation(project(":transaction")) + implementation(project(":core:data-model")) + implementation(project(":widgets")) + implementation(project(":main:home")) + implementation(project(":main:accounts")) + implementation(project(":main:more-menu")) + implementation(project(":app-locked")) + implementation(project(":android:billing")) + implementation(project(":android:notifications")) + implementation(project(":core:exchange-provider")) + implementation(project(":core:domain")) + implementation(project(":debug")) + implementation(project(":onboarding")) + implementation(project(":android:common")) + implementation(project(":android:file-system")) + implementation(project(":drive:google-drive")) + implementation(project(":photo-frame")) + implementation(project(":backup:api")) + implementation(project(":exchange-rates")) + + Hilt() + + Google() + Firebase() + + RoomDB(api = false) + + Networking(api = false) + Testing() + + DataStore(api = false) + + ThirdParty() +} diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 0000000..bc0dcf3 --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,85 @@ +{ + "project_info": { + "project_number": "364763737033", + "firebase_url": "https://ivy-widget.firebaseio.com", + "project_id": "ivy-widget", + "storage_bucket": "ivy-widget.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:364763737033:android:634d1eb97e9254b3bc5618", + "android_client_info": { + "package_name": "com.ivy.wallet" + } + }, + "oauth_client": [ + { + "client_id": "364763737033-k9vbsfmeou12rscq134lclc7sbr0608e.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.ivy.wallet", + "certificate_hash": "f9b8505c2740756c704f8782b6fc3ddcaaea3d80" + } + }, + { + "client_id": "364763737033-t1d2qe7s0s8597k7anu3sb2nq79ot5tp.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyB4FVF29Qu17MhHmf-55l7l7W09hiMQuJM" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "364763737033-8f0ta4r3t5cjmavkhmdnjcufpcg6ror6.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:364763737033:android:7e379f986cb86320bc5618", + "android_client_info": { + "package_name": "com.ivy.wallet.debug" + } + }, + "oauth_client": [ + { + "client_id": "364763737033-ktnhou2u7q3num6aj02jj0v11dnp5imi.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.ivy.wallet.debug", + "certificate_hash": "962730459b106b8aa8fb225430deb6a97c559699" + } + }, + { + "client_id": "364763737033-t1d2qe7s0s8597k7anu3sb2nq79ot5tp.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyB4FVF29Qu17MhHmf-55l7l7W09hiMQuJM" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "364763737033-8f0ta4r3t5cjmavkhmdnjcufpcg6ror6.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..0f815cb --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,313 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +#Fix Crashig "Donate" scree (Jetpack Compose internal crash) +-keep class com.ivy.wallet.ui.donate.** { *; } +-keep class com.ivy.frp.** { *; } +-keep class com.ivy.frp.view.FRPComposableKt { *; } + +# Fix broken stuff by R8 +-keep class com.ivy.wallet.ui.widget.** { *; } +-keep class com.ivy.wallet.domain.data.** { *; } +-keep class com.ivy.wallet.io.network.** { *; } +-keep class com.ivy.wallet.io.persistence.data.** { *; } +-keep class com.ivy.wallet.io.network.data.** { *; } +-keep class com.ivy.wallet.domain.event.** { *; } + +-keepattributes EnclosingMethod +-keepattributes InnerClasses + +#Jetpack Datastore +-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite { + ; +} + +# Firebase Crashlytics +-dontwarn org.xmlpull.v1.** +-dontnote org.xmlpull.v1.** +-keep class org.xmlpull.** { *; } +-keepclassmembers class org.xmlpull.** { *; } + +-keepattributes *Annotation* +-keepattributes SourceFile,LineNumberTable +-keep public class * extends java.lang.Exception + +-keep class com.crashlytics.** { *; } +-dontwarn com.crashlytics.** +#------ + +# Retrofit2 +-dontwarn retrofit2.** +-keep class retrofit2.** { *; } +-keepattributes Signature +-keepattributes Exceptions + +-keepclasseswithmembers class * { + @retrofit2.http.* ; +} +#------ + +# Timber + Log.d() +-assumenosideeffects class android.util.Log { + public static boolean isLoggable(java.lang.String, int); + public static int v(...); + public static int d(...); + public static int i(...); + public static int w(...); + public static int e(...); +} + +-assumenosideeffects class timber.log.Timber* { + public static *** v(...); + public static *** d(...); + public static *** i(...); + public static *** w(...); + public static *** e(...); +} +#------ + +# OkHttp +-keepattributes Signature +-keepattributes *Annotation* +-keep class okhttp3.** { *; } +-keep interface okhttp3.** { *; } +-dontwarn okhttp3.** + +# Okio +-keep class sun.misc.Unsafe { *; } +-dontwarn java.nio.file.* +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement +-dontwarn okio.** +#---= + +# Enums +-keepclassmembers class * extends java.lang.Enum { + ; + public static **[] values(); + public static ** valueOf(java.lang.String); +} +#----- + +# Google Play GooglePlayBilling +-keep class com.android.vending.billing.** +#---- + +# Glide +-keep public class * implements com.bumptech.glide.module.GlideModule +-keep public class * extends com.bumptech.glide.module.AppGlideModule +-keep public enum com.bumptech.glide.load.ImageHeaderParser$** { + **[] $VALUES; + public *; +} +#------ + +# EventBus +-keepattributes *Annotation* +-keepclassmembers class * { + @org.greenrobot.eventbus.Subscribe ; +} +-keep enum org.greenrobot.eventbus.ThreadMode { *; } + +# And if you use AsyncExecutor: +-keepclassmembers class * extends org.greenrobot.eventbus.util.ThrowableFailureEvent { + (java.lang.Throwable); +} +#------ + + +##---------------Begin: proguard configuration for Gson ---------- +# Gson uses generic type information stored in a class file when working with fields. Proguard +# removes such information by default, so configure it to keep all of it. +-keepattributes Signature + +# For using GSON @Expose annotation +-keepattributes *Annotation* + +# Gson specific classes +-dontwarn sun.misc.** +#-keep class com.google.gson.stream.** { *; } + +# Application classes that will be serialized/deserialized over Gson +-keep class com.ivy.wallet.model.** { ; } + +# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, +# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) +-keep class * implements com.google.gson.TypeAdapter +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer + +# Prevent R8 from leaving Data object members always null +-keepclassmembers,allowobfuscation class * { + @com.google.gson.annotations.SerializedName ; +} + +##---------------End: proguard configuration for Gson ---------- + +# Prevent obfuscation of types which use ButterKnife annotations since the simple name +# is used to reflectively look up the generated ViewBinding. +-keep class butterknife.* +-keepclasseswithmembernames class * { @butterknife.* ; } +-keepclasseswithmembernames class * { @butterknife.* ; } +#------- + + +# Joda Time +-dontwarn org.joda.convert.FromString +-dontwarn org.joda.convert.ToString +# ------- + +# Keep class names of Hilt injected ViewModels since their name are used as a multibinding map key. +-keepnames @dagger.hilt.android.lifecycle.HiltViewModel class * extends androidx.lifecycle.ViewModel + +# EventBus ---------------------------------------------------------------- +-keepattributes *Annotation* +-keepclassmembers class * { + @org.greenrobot.eventbus.Subscribe ; +} +-keep enum org.greenrobot.eventbus.ThreadMode { *; } + +# And if you use AsyncExecutor: +-keepclassmembers class * extends org.greenrobot.eventbus.util.ThrowableFailureEvent { + (java.lang.Throwable); +} +# EventBus --------------------------------------------------------------------- + +# Glide ------------------- +-keep public class * implements com.bumptech.glide.module.GlideModule +-keep class * extends com.bumptech.glide.module.AppGlideModule { + (...); +} +-keep public enum com.bumptech.glide.load.ImageHeaderParser$** { + **[] $VALUES; + public *; +} +-keep class com.bumptech.glide.load.data.ParcelFileDescriptorRewinder$InternalRewinder { + *** rewind(); +} +# Glide ------------------- + + +# Work manager proguard rules +-keep class * extends androidx.work.Worker +-keep class * extends androidx.work.InputMerger +# Keep all constructors on ListenableWorker, Worker (also marked with @Keep) +-keep public class * extends androidx.work.ListenableWorker { + public (...); +} +# We need to keep WorkerParameters for the ListenableWorker constructor +-keep class androidx.work.WorkerParameters + + +# Presever Crashlytics deobfuscated logs +-keepattributes SourceFile,LineNumberTable # Keep file names and line numbers. +-keep public class * extends java.lang.Exception # Optional: Keep custom exceptions. + +# Zxing Barcode Scanner +-keep class me.dm7.barcodescanner.** { *; } +-keep class net.sourceforge.zbar.** { *; } + +# Enums +-keepclassmembers class * extends java.lang.Enum { + ; + public static **[] values(); + public static ** valueOf(java.lang.String); +} +#----- + +# Retrofit2 +-dontwarn retrofit2.** +-keep class retrofit2.** { *; } +-keepattributes Signature +-keepattributes Exceptions + +-keepclasseswithmembers class * { + @retrofit2.http.* ; +} +#------ + +# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, +# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) +-keep class * implements com.google.gson.TypeAdapter +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer + +# Prevent R8 from leaving Data object members always null +-keepclassmembers,allowobfuscation class * { + @com.google.gson.annotations.SerializedName ; +} + +# Timber + Log.d() +-assumenosideeffects class android.util.Log { + public static boolean isLoggable(java.lang.String, int); + public static int v(...); + public static int d(...); + public static int i(...); + public static int w(...); + public static int e(...); +} + +-assumenosideeffects class timber.log.Timber* { + public static *** v(...); + public static *** d(...); + public static *** i(...); + public static *** w(...); + public static *** e(...); +} +#------ + +# Disable Exception#printStackTrack() on prod +-assumenosideeffects class java.lang.Throwable { + public void printStackTrace(...); +} + + +# Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and +# EnclosingMethod is required to use InnerClasses. +-keepattributes Signature, InnerClasses, EnclosingMethod + +# Retrofit does reflection on method and parameter annotations. +-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations + +# Retain service method parameters when optimizing. +-keepclassmembers,allowshrinking,allowobfuscation interface * { + @retrofit2.http.* ; +} + +# Ignore annotation used for build tooling. +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement + +# Ignore JSR 305 annotations for embedding nullability information. +-dontwarn javax.annotation.** + +# Guarded by a NoClassDefFoundError try/catch and only used when on the classpath. +-dontwarn kotlin.Unit + +# Top-level functions that can only be used by Kotlin. +-dontwarn retrofit2.KotlinExtensions +-dontwarn retrofit2.KotlinExtensions$* + +# With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy +# and replaces all potential values with null. Explicitly keeping the interfaces prevents this. +-if interface * { @retrofit2.http.* ; } +-keep,allowobfuscation interface <1> \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.io.persistence.IvyRoomDatabase/120.json b/app/schemas/com.ivy.wallet.io.persistence.IvyRoomDatabase/120.json new file mode 100644 index 0000000..84070e1 --- /dev/null +++ b/app/schemas/com.ivy.wallet.io.persistence.IvyRoomDatabase/120.json @@ -0,0 +1,793 @@ +{ + "formatVersion": 1, + "database": { + "version": 120, + "identityHash": "751c82ed72a54493f42000cd47a99137", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `currency` TEXT, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `includeInBalance` INTEGER NOT NULL, `seAccountId` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "includeInBalance", + "columnName": "includeInBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seAccountId", + "columnName": "seAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `toAmount` REAL, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `recurringRuleId` TEXT, `attachmentUrl` TEXT, `loanId` TEXT, `loanRecordId` TEXT, `seTransactionId` TEXT, `seAutoCategoryId` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "toAmount", + "columnName": "toAmount", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recurringRuleId", + "columnName": "recurringRuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loanId", + "columnName": "loanId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loanRecordId", + "columnName": "loanRecordId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "seTransactionId", + "columnName": "seTransactionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "seAutoCategoryId", + "columnName": "seAutoCategoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `seCategoryName` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "seCategoryName", + "columnName": "seCategoryName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `accountId` TEXT NOT NULL, `categoryId` TEXT, `description` TEXT, `plannedDateTime` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "plannedDateTime", + "columnName": "plannedDateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "planned_payment_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`startDate` INTEGER, `intervalN` INTEGER, `intervalType` TEXT, `oneTime` INTEGER NOT NULL, `type` TEXT NOT NULL, `accountId` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryId` TEXT, `title` TEXT, `description` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalN", + "columnName": "intervalN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalType", + "columnName": "intervalType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "oneTime", + "columnName": "oneTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `authProviderType` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT, `profilePicture` TEXT, `color` INTEGER NOT NULL, `testUser` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authProviderType", + "columnName": "authProviderType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "profilePicture", + "columnName": "profilePicture", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "testUser", + "columnName": "testUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "baseCurrency", + "currency" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "budgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryIdsSerialized` TEXT, `accountIdsSerialized` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `orderId` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryIdsSerialized", + "columnName": "categoryIdsSerialized", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountIdsSerialized", + "columnName": "accountIdsSerialized", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderId", + "columnName": "orderId", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "loans", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `amount` REAL NOT NULL, `type` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `accountId` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "loan_records", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`loanId` TEXT NOT NULL, `amount` REAL NOT NULL, `note` TEXT, `dateTime` INTEGER NOT NULL, `interest` INTEGER NOT NULL, `accountId` TEXT, `convertedAmount` REAL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "loanId", + "columnName": "loanId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "interest", + "columnName": "interest", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "convertedAmount", + "columnName": "convertedAmount", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '751c82ed72a54493f42000cd47a99137')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.io.persistence.IvyRoomDatabase/121.json b/app/schemas/com.ivy.wallet.io.persistence.IvyRoomDatabase/121.json new file mode 100644 index 0000000..83dd1a6 --- /dev/null +++ b/app/schemas/com.ivy.wallet.io.persistence.IvyRoomDatabase/121.json @@ -0,0 +1,707 @@ +{ + "formatVersion": 1, + "database": { + "version": 121, + "identityHash": "319b13332051b3e936a5902b2b7a2ad5", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `currency` TEXT, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `includeInBalance` INTEGER NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "includeInBalance", + "columnName": "includeInBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `toAmount` REAL, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `recurringRuleId` TEXT, `attachmentUrl` TEXT, `loanId` TEXT, `loanRecordId` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "toAmount", + "columnName": "toAmount", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recurringRuleId", + "columnName": "recurringRuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loanId", + "columnName": "loanId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loanRecordId", + "columnName": "loanRecordId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "planned_payment_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`startDate` INTEGER, `intervalN` INTEGER, `intervalType` TEXT, `oneTime` INTEGER NOT NULL, `type` TEXT NOT NULL, `accountId` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryId` TEXT, `title` TEXT, `description` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalN", + "columnName": "intervalN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalType", + "columnName": "intervalType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "oneTime", + "columnName": "oneTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `authProviderType` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT, `profilePicture` TEXT, `color` INTEGER NOT NULL, `testUser` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authProviderType", + "columnName": "authProviderType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "profilePicture", + "columnName": "profilePicture", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "testUser", + "columnName": "testUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "baseCurrency", + "currency" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "budgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryIdsSerialized` TEXT, `accountIdsSerialized` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `orderId` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryIdsSerialized", + "columnName": "categoryIdsSerialized", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountIdsSerialized", + "columnName": "accountIdsSerialized", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderId", + "columnName": "orderId", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "loans", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `amount` REAL NOT NULL, `type` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `accountId` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "loan_records", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`loanId` TEXT NOT NULL, `amount` REAL NOT NULL, `note` TEXT, `dateTime` INTEGER NOT NULL, `interest` INTEGER NOT NULL, `accountId` TEXT, `convertedAmount` REAL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "loanId", + "columnName": "loanId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "interest", + "columnName": "interest", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "convertedAmount", + "columnName": "convertedAmount", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '319b13332051b3e936a5902b2b7a2ad5')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.io.persistence.IvyRoomDatabase/122.json b/app/schemas/com.ivy.wallet.io.persistence.IvyRoomDatabase/122.json new file mode 100644 index 0000000..295aa84 --- /dev/null +++ b/app/schemas/com.ivy.wallet.io.persistence.IvyRoomDatabase/122.json @@ -0,0 +1,707 @@ +{ + "formatVersion": 1, + "database": { + "version": 122, + "identityHash": "319b13332051b3e936a5902b2b7a2ad5", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `currency` TEXT, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `includeInBalance` INTEGER NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "includeInBalance", + "columnName": "includeInBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `toAmount` REAL, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `recurringRuleId` TEXT, `attachmentUrl` TEXT, `loanId` TEXT, `loanRecordId` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "toAmount", + "columnName": "toAmount", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recurringRuleId", + "columnName": "recurringRuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loanId", + "columnName": "loanId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loanRecordId", + "columnName": "loanRecordId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "planned_payment_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`startDate` INTEGER, `intervalN` INTEGER, `intervalType` TEXT, `oneTime` INTEGER NOT NULL, `type` TEXT NOT NULL, `accountId` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryId` TEXT, `title` TEXT, `description` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalN", + "columnName": "intervalN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalType", + "columnName": "intervalType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "oneTime", + "columnName": "oneTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `authProviderType` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT, `profilePicture` TEXT, `color` INTEGER NOT NULL, `testUser` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authProviderType", + "columnName": "authProviderType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "profilePicture", + "columnName": "profilePicture", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "testUser", + "columnName": "testUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "baseCurrency", + "currency" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "budgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryIdsSerialized` TEXT, `accountIdsSerialized` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `orderId` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryIdsSerialized", + "columnName": "categoryIdsSerialized", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountIdsSerialized", + "columnName": "accountIdsSerialized", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderId", + "columnName": "orderId", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "loans", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `amount` REAL NOT NULL, `type` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `accountId` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "loan_records", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`loanId` TEXT NOT NULL, `amount` REAL NOT NULL, `note` TEXT, `dateTime` INTEGER NOT NULL, `interest` INTEGER NOT NULL, `accountId` TEXT, `convertedAmount` REAL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "loanId", + "columnName": "loanId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "interest", + "columnName": "interest", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "convertedAmount", + "columnName": "convertedAmount", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '319b13332051b3e936a5902b2b7a2ad5')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.io.persistence.IvyRoomDatabase/123.json b/app/schemas/com.ivy.wallet.io.persistence.IvyRoomDatabase/123.json new file mode 100644 index 0000000..1165500 --- /dev/null +++ b/app/schemas/com.ivy.wallet.io.persistence.IvyRoomDatabase/123.json @@ -0,0 +1,713 @@ +{ + "formatVersion": 1, + "database": { + "version": 123, + "identityHash": "53cba3d6595ca41b4f6966577609da7c", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `currency` TEXT, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `includeInBalance` INTEGER NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "includeInBalance", + "columnName": "includeInBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `toAmount` REAL, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `recurringRuleId` TEXT, `attachmentUrl` TEXT, `loanId` TEXT, `loanRecordId` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "toAmount", + "columnName": "toAmount", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recurringRuleId", + "columnName": "recurringRuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loanId", + "columnName": "loanId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loanRecordId", + "columnName": "loanRecordId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "planned_payment_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`startDate` INTEGER, `intervalN` INTEGER, `intervalType` TEXT, `oneTime` INTEGER NOT NULL, `type` TEXT NOT NULL, `accountId` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryId` TEXT, `title` TEXT, `description` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalN", + "columnName": "intervalN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalType", + "columnName": "intervalType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "oneTime", + "columnName": "oneTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `authProviderType` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT, `profilePicture` TEXT, `color` INTEGER NOT NULL, `testUser` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authProviderType", + "columnName": "authProviderType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "profilePicture", + "columnName": "profilePicture", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "testUser", + "columnName": "testUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, `manualOverride` INTEGER NOT NULL, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "manualOverride", + "columnName": "manualOverride", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "baseCurrency", + "currency" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "budgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryIdsSerialized` TEXT, `accountIdsSerialized` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `orderId` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryIdsSerialized", + "columnName": "categoryIdsSerialized", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountIdsSerialized", + "columnName": "accountIdsSerialized", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderId", + "columnName": "orderId", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "loans", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `amount` REAL NOT NULL, `type` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `accountId` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "loan_records", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`loanId` TEXT NOT NULL, `amount` REAL NOT NULL, `note` TEXT, `dateTime` INTEGER NOT NULL, `interest` INTEGER NOT NULL, `accountId` TEXT, `convertedAmount` REAL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "loanId", + "columnName": "loanId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "interest", + "columnName": "interest", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "convertedAmount", + "columnName": "convertedAmount", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '53cba3d6595ca41b4f6966577609da7c')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/101.json b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/101.json new file mode 100644 index 0000000..cd448c2 --- /dev/null +++ b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/101.json @@ -0,0 +1,270 @@ +{ + "formatVersion": 1, + "database": { + "version": 101, + "identityHash": "058218382f67b291520431bb9ce15fae", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `name` TEXT, `description` TEXT, `dateTime` INTEGER NOT NULL, `categoryId` TEXT, `dueDate` INTEGER, `attachmentUrl` TEXT, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `categoryId` TEXT, `link` TEXT, `buyThisMonth` INTEGER NOT NULL, `boughtDate` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "buyThisMonth", + "columnName": "buyThisMonth", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "boughtDate", + "columnName": "boughtDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '058218382f67b291520431bb9ce15fae')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/102.json b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/102.json new file mode 100644 index 0000000..458a763 --- /dev/null +++ b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/102.json @@ -0,0 +1,270 @@ +{ + "formatVersion": 1, + "database": { + "version": 102, + "identityHash": "058218382f67b291520431bb9ce15fae", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `name` TEXT, `description` TEXT, `dateTime` INTEGER NOT NULL, `categoryId` TEXT, `dueDate` INTEGER, `attachmentUrl` TEXT, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `categoryId` TEXT, `link` TEXT, `buyThisMonth` INTEGER NOT NULL, `boughtDate` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "buyThisMonth", + "columnName": "buyThisMonth", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "boughtDate", + "columnName": "boughtDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '058218382f67b291520431bb9ce15fae')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/103.json b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/103.json new file mode 100644 index 0000000..e98d103 --- /dev/null +++ b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/103.json @@ -0,0 +1,276 @@ +{ + "formatVersion": 1, + "database": { + "version": 103, + "identityHash": "12d96fa79ea84c5cf75e13384343016b", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `name` TEXT, `description` TEXT, `dateTime` INTEGER NOT NULL, `categoryId` TEXT, `dueDate` INTEGER, `attachmentUrl` TEXT, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `categoryId` TEXT, `link` TEXT, `buyThisMonth` INTEGER NOT NULL, `boughtDate` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "buyThisMonth", + "columnName": "buyThisMonth", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "boughtDate", + "columnName": "boughtDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '12d96fa79ea84c5cf75e13384343016b')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/104.json b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/104.json new file mode 100644 index 0000000..10babcf --- /dev/null +++ b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/104.json @@ -0,0 +1,276 @@ +{ + "formatVersion": 1, + "database": { + "version": 104, + "identityHash": "22c59e544733ebbd22440bd700bd4f54", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `attachmentUrl` TEXT, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `categoryId` TEXT, `link` TEXT, `buyThisMonth` INTEGER NOT NULL, `boughtDate` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "buyThisMonth", + "columnName": "buyThisMonth", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "boughtDate", + "columnName": "boughtDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '22c59e544733ebbd22440bd700bd4f54')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/105.json b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/105.json new file mode 100644 index 0000000..82750ea --- /dev/null +++ b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/105.json @@ -0,0 +1,282 @@ +{ + "formatVersion": 1, + "database": { + "version": 105, + "identityHash": "6bbf877def1b0b5663b73cb78562906d", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `recurringRuleId` TEXT, `attachmentUrl` TEXT, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recurringRuleId", + "columnName": "recurringRuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `categoryId` TEXT, `link` TEXT, `buyThisMonth` INTEGER NOT NULL, `boughtDate` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "buyThisMonth", + "columnName": "buyThisMonth", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "boughtDate", + "columnName": "boughtDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6bbf877def1b0b5663b73cb78562906d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/106.json b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/106.json new file mode 100644 index 0000000..4ca1c3a --- /dev/null +++ b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/106.json @@ -0,0 +1,350 @@ +{ + "formatVersion": 1, + "database": { + "version": 106, + "identityHash": "3c9fc550c2a2ae5bb708a8624d81ce11", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `recurringRuleId` TEXT, `attachmentUrl` TEXT, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recurringRuleId", + "columnName": "recurringRuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `categoryId` TEXT, `link` TEXT, `buyThisMonth` INTEGER NOT NULL, `boughtDate` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "buyThisMonth", + "columnName": "buyThisMonth", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "boughtDate", + "columnName": "boughtDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transaction_recurring_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`startDate` INTEGER NOT NULL, `intervalSeconds` INTEGER NOT NULL, `type` TEXT NOT NULL, `accountId` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryId` TEXT, `title` TEXT, `description` TEXT, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "intervalSeconds", + "columnName": "intervalSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3c9fc550c2a2ae5bb708a8624d81ce11')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/107.json b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/107.json new file mode 100644 index 0000000..1079395 --- /dev/null +++ b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/107.json @@ -0,0 +1,350 @@ +{ + "formatVersion": 1, + "database": { + "version": 107, + "identityHash": "bbe42a8a2d295b0329c11ff0ec543d59", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `recurringRuleId` TEXT, `attachmentUrl` TEXT, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recurringRuleId", + "columnName": "recurringRuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `accountId` TEXT NOT NULL, `categoryId` TEXT, `description` TEXT, `plannedDateTime` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "plannedDateTime", + "columnName": "plannedDateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transaction_recurring_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`startDate` INTEGER NOT NULL, `intervalSeconds` INTEGER NOT NULL, `type` TEXT NOT NULL, `accountId` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryId` TEXT, `title` TEXT, `description` TEXT, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "intervalSeconds", + "columnName": "intervalSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bbe42a8a2d295b0329c11ff0ec543d59')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/108.json b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/108.json new file mode 100644 index 0000000..abd4aa3 --- /dev/null +++ b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/108.json @@ -0,0 +1,398 @@ +{ + "formatVersion": 1, + "database": { + "version": 108, + "identityHash": "00a5d1cc413d18f8f4bf30cd783fe825", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `recurringRuleId` TEXT, `attachmentUrl` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recurringRuleId", + "columnName": "recurringRuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `accountId` TEXT NOT NULL, `categoryId` TEXT, `description` TEXT, `plannedDateTime` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "plannedDateTime", + "columnName": "plannedDateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transaction_recurring_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`startDate` INTEGER NOT NULL, `intervalSeconds` INTEGER NOT NULL, `type` TEXT NOT NULL, `accountId` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryId` TEXT, `title` TEXT, `description` TEXT, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "intervalSeconds", + "columnName": "intervalSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '00a5d1cc413d18f8f4bf30cd783fe825')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/109.json b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/109.json new file mode 100644 index 0000000..ae01ec5 --- /dev/null +++ b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/109.json @@ -0,0 +1,454 @@ +{ + "formatVersion": 1, + "database": { + "version": 109, + "identityHash": "da27cd163864346a2a3cba8249eb04ba", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `recurringRuleId` TEXT, `attachmentUrl` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recurringRuleId", + "columnName": "recurringRuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `accountId` TEXT NOT NULL, `categoryId` TEXT, `description` TEXT, `plannedDateTime` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "plannedDateTime", + "columnName": "plannedDateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transaction_recurring_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`startDate` INTEGER NOT NULL, `intervalSeconds` INTEGER NOT NULL, `type` TEXT NOT NULL, `accountId` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryId` TEXT, `title` TEXT, `description` TEXT, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "intervalSeconds", + "columnName": "intervalSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `authProviderType` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT, `profilePicture` TEXT, `color` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authProviderType", + "columnName": "authProviderType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "profilePicture", + "columnName": "profilePicture", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'da27cd163864346a2a3cba8249eb04ba')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/110.json b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/110.json new file mode 100644 index 0000000..b84d4d1 --- /dev/null +++ b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/110.json @@ -0,0 +1,478 @@ +{ + "formatVersion": 1, + "database": { + "version": 110, + "identityHash": "493cd3e3ff477007e1d2112a2e6e7fa6", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `recurringRuleId` TEXT, `attachmentUrl` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recurringRuleId", + "columnName": "recurringRuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `accountId` TEXT NOT NULL, `categoryId` TEXT, `description` TEXT, `plannedDateTime` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "plannedDateTime", + "columnName": "plannedDateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transaction_recurring_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`startDate` INTEGER, `intervalN` INTEGER, `intervalType` TEXT, `oneTime` INTEGER NOT NULL, `type` TEXT NOT NULL, `accountId` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryId` TEXT, `title` TEXT, `description` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalN", + "columnName": "intervalN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalType", + "columnName": "intervalType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "oneTime", + "columnName": "oneTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `authProviderType` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT, `profilePicture` TEXT, `color` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authProviderType", + "columnName": "authProviderType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "profilePicture", + "columnName": "profilePicture", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '493cd3e3ff477007e1d2112a2e6e7fa6')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/111.json b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/111.json new file mode 100644 index 0000000..ad05b6e --- /dev/null +++ b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/111.json @@ -0,0 +1,478 @@ +{ + "formatVersion": 1, + "database": { + "version": 111, + "identityHash": "3dcf9df0877cacec9bddefa43adca748", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `recurringRuleId` TEXT, `attachmentUrl` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recurringRuleId", + "columnName": "recurringRuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `accountId` TEXT NOT NULL, `categoryId` TEXT, `description` TEXT, `plannedDateTime` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "plannedDateTime", + "columnName": "plannedDateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "planned_payment_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`startDate` INTEGER, `intervalN` INTEGER, `intervalType` TEXT, `oneTime` INTEGER NOT NULL, `type` TEXT NOT NULL, `accountId` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryId` TEXT, `title` TEXT, `description` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalN", + "columnName": "intervalN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalType", + "columnName": "intervalType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "oneTime", + "columnName": "oneTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `authProviderType` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT, `profilePicture` TEXT, `color` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authProviderType", + "columnName": "authProviderType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "profilePicture", + "columnName": "profilePicture", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3dcf9df0877cacec9bddefa43adca748')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/112.json b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/112.json new file mode 100644 index 0000000..c415c12 --- /dev/null +++ b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/112.json @@ -0,0 +1,484 @@ +{ + "formatVersion": 1, + "database": { + "version": 112, + "identityHash": "df82344427c479524a89f1c319bf35e1", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `recurringRuleId` TEXT, `attachmentUrl` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recurringRuleId", + "columnName": "recurringRuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `accountId` TEXT NOT NULL, `categoryId` TEXT, `description` TEXT, `plannedDateTime` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "plannedDateTime", + "columnName": "plannedDateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "planned_payment_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`startDate` INTEGER, `intervalN` INTEGER, `intervalType` TEXT, `oneTime` INTEGER NOT NULL, `type` TEXT NOT NULL, `accountId` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryId` TEXT, `title` TEXT, `description` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalN", + "columnName": "intervalN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalType", + "columnName": "intervalType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "oneTime", + "columnName": "oneTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `authProviderType` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT, `profilePicture` TEXT, `color` INTEGER NOT NULL, `testUser` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authProviderType", + "columnName": "authProviderType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "profilePicture", + "columnName": "profilePicture", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "testUser", + "columnName": "testUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'df82344427c479524a89f1c319bf35e1')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/113.json b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/113.json new file mode 100644 index 0000000..9f7dc84 --- /dev/null +++ b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/113.json @@ -0,0 +1,523 @@ +{ + "formatVersion": 1, + "database": { + "version": 113, + "identityHash": "c166276701cd275cee0a920d1283201e", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `currency` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `recurringRuleId` TEXT, `attachmentUrl` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recurringRuleId", + "columnName": "recurringRuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `accountId` TEXT NOT NULL, `categoryId` TEXT, `description` TEXT, `plannedDateTime` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "plannedDateTime", + "columnName": "plannedDateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "planned_payment_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`startDate` INTEGER, `intervalN` INTEGER, `intervalType` TEXT, `oneTime` INTEGER NOT NULL, `type` TEXT NOT NULL, `accountId` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryId` TEXT, `title` TEXT, `description` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalN", + "columnName": "intervalN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalType", + "columnName": "intervalType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "oneTime", + "columnName": "oneTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `authProviderType` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT, `profilePicture` TEXT, `color` INTEGER NOT NULL, `testUser` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authProviderType", + "columnName": "authProviderType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "profilePicture", + "columnName": "profilePicture", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "testUser", + "columnName": "testUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "baseCurrency", + "currency" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c166276701cd275cee0a920d1283201e')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/114.json b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/114.json new file mode 100644 index 0000000..14684bf --- /dev/null +++ b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/114.json @@ -0,0 +1,529 @@ +{ + "formatVersion": 1, + "database": { + "version": 114, + "identityHash": "a2f12de35a6edf8c0799449fcf871608", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `currency` TEXT, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `toAmount` REAL, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `recurringRuleId` TEXT, `attachmentUrl` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "toAmount", + "columnName": "toAmount", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recurringRuleId", + "columnName": "recurringRuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `orderNum` REAL NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `accountId` TEXT NOT NULL, `categoryId` TEXT, `description` TEXT, `plannedDateTime` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "plannedDateTime", + "columnName": "plannedDateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "planned_payment_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`startDate` INTEGER, `intervalN` INTEGER, `intervalType` TEXT, `oneTime` INTEGER NOT NULL, `type` TEXT NOT NULL, `accountId` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryId` TEXT, `title` TEXT, `description` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalN", + "columnName": "intervalN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalType", + "columnName": "intervalType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "oneTime", + "columnName": "oneTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `authProviderType` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT, `profilePicture` TEXT, `color` INTEGER NOT NULL, `testUser` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authProviderType", + "columnName": "authProviderType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "profilePicture", + "columnName": "profilePicture", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "testUser", + "columnName": "testUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "baseCurrency", + "currency" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a2f12de35a6edf8c0799449fcf871608')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/115.json b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/115.json new file mode 100644 index 0000000..a5073e3 --- /dev/null +++ b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/115.json @@ -0,0 +1,541 @@ +{ + "formatVersion": 1, + "database": { + "version": 115, + "identityHash": "ffb8f521925878dbb52afe680c6af0c4", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `currency` TEXT, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `toAmount` REAL, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `recurringRuleId` TEXT, `attachmentUrl` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "toAmount", + "columnName": "toAmount", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recurringRuleId", + "columnName": "recurringRuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `accountId` TEXT NOT NULL, `categoryId` TEXT, `description` TEXT, `plannedDateTime` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "plannedDateTime", + "columnName": "plannedDateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "planned_payment_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`startDate` INTEGER, `intervalN` INTEGER, `intervalType` TEXT, `oneTime` INTEGER NOT NULL, `type` TEXT NOT NULL, `accountId` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryId` TEXT, `title` TEXT, `description` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalN", + "columnName": "intervalN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalType", + "columnName": "intervalType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "oneTime", + "columnName": "oneTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `authProviderType` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT, `profilePicture` TEXT, `color` INTEGER NOT NULL, `testUser` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authProviderType", + "columnName": "authProviderType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "profilePicture", + "columnName": "profilePicture", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "testUser", + "columnName": "testUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "baseCurrency", + "currency" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ffb8f521925878dbb52afe680c6af0c4')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/116.json b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/116.json new file mode 100644 index 0000000..952e879 --- /dev/null +++ b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/116.json @@ -0,0 +1,547 @@ +{ + "formatVersion": 1, + "database": { + "version": 116, + "identityHash": "469434695672a9bd9fd09e9ff2b1a38f", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `currency` TEXT, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `includeInBalance` INTEGER NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "includeInBalance", + "columnName": "includeInBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `toAmount` REAL, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `recurringRuleId` TEXT, `attachmentUrl` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "toAmount", + "columnName": "toAmount", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recurringRuleId", + "columnName": "recurringRuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `accountId` TEXT NOT NULL, `categoryId` TEXT, `description` TEXT, `plannedDateTime` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "plannedDateTime", + "columnName": "plannedDateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "planned_payment_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`startDate` INTEGER, `intervalN` INTEGER, `intervalType` TEXT, `oneTime` INTEGER NOT NULL, `type` TEXT NOT NULL, `accountId` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryId` TEXT, `title` TEXT, `description` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalN", + "columnName": "intervalN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalType", + "columnName": "intervalType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "oneTime", + "columnName": "oneTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `authProviderType` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT, `profilePicture` TEXT, `color` INTEGER NOT NULL, `testUser` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authProviderType", + "columnName": "authProviderType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "profilePicture", + "columnName": "profilePicture", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "testUser", + "columnName": "testUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "baseCurrency", + "currency" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '469434695672a9bd9fd09e9ff2b1a38f')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/117.json b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/117.json new file mode 100644 index 0000000..41f31ba --- /dev/null +++ b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/117.json @@ -0,0 +1,571 @@ +{ + "formatVersion": 1, + "database": { + "version": 117, + "identityHash": "1f4b072bb30439d826fc23dd04821c40", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `currency` TEXT, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `includeInBalance` INTEGER NOT NULL, `seAccountId` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "includeInBalance", + "columnName": "includeInBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seAccountId", + "columnName": "seAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `toAmount` REAL, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `recurringRuleId` TEXT, `attachmentUrl` TEXT, `seTransactionId` TEXT, `seAutoCategoryId` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "toAmount", + "columnName": "toAmount", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recurringRuleId", + "columnName": "recurringRuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "seTransactionId", + "columnName": "seTransactionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "seAutoCategoryId", + "columnName": "seAutoCategoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `seCategoryName` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "seCategoryName", + "columnName": "seCategoryName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `accountId` TEXT NOT NULL, `categoryId` TEXT, `description` TEXT, `plannedDateTime` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "plannedDateTime", + "columnName": "plannedDateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "planned_payment_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`startDate` INTEGER, `intervalN` INTEGER, `intervalType` TEXT, `oneTime` INTEGER NOT NULL, `type` TEXT NOT NULL, `accountId` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryId` TEXT, `title` TEXT, `description` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalN", + "columnName": "intervalN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalType", + "columnName": "intervalType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "oneTime", + "columnName": "oneTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `authProviderType` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT, `profilePicture` TEXT, `color` INTEGER NOT NULL, `testUser` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authProviderType", + "columnName": "authProviderType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "profilePicture", + "columnName": "profilePicture", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "testUser", + "columnName": "testUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "baseCurrency", + "currency" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1f4b072bb30439d826fc23dd04821c40')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/118.json b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/118.json new file mode 100644 index 0000000..c9bdc98 --- /dev/null +++ b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/118.json @@ -0,0 +1,633 @@ +{ + "formatVersion": 1, + "database": { + "version": 118, + "identityHash": "df0b90648eeee6d198a1a4d71e689919", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `currency` TEXT, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `includeInBalance` INTEGER NOT NULL, `seAccountId` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "includeInBalance", + "columnName": "includeInBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seAccountId", + "columnName": "seAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `toAmount` REAL, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `recurringRuleId` TEXT, `attachmentUrl` TEXT, `seTransactionId` TEXT, `seAutoCategoryId` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "toAmount", + "columnName": "toAmount", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recurringRuleId", + "columnName": "recurringRuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "seTransactionId", + "columnName": "seTransactionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "seAutoCategoryId", + "columnName": "seAutoCategoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `seCategoryName` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "seCategoryName", + "columnName": "seCategoryName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `accountId` TEXT NOT NULL, `categoryId` TEXT, `description` TEXT, `plannedDateTime` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "plannedDateTime", + "columnName": "plannedDateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "planned_payment_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`startDate` INTEGER, `intervalN` INTEGER, `intervalType` TEXT, `oneTime` INTEGER NOT NULL, `type` TEXT NOT NULL, `accountId` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryId` TEXT, `title` TEXT, `description` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalN", + "columnName": "intervalN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalType", + "columnName": "intervalType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "oneTime", + "columnName": "oneTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `authProviderType` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT, `profilePicture` TEXT, `color` INTEGER NOT NULL, `testUser` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authProviderType", + "columnName": "authProviderType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "profilePicture", + "columnName": "profilePicture", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "testUser", + "columnName": "testUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "baseCurrency", + "currency" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "budgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryIdsSerialized` TEXT, `accountIdsSerialized` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `orderId` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryIdsSerialized", + "columnName": "categoryIdsSerialized", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountIdsSerialized", + "columnName": "accountIdsSerialized", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderId", + "columnName": "orderId", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'df0b90648eeee6d198a1a4d71e689919')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/119.json b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/119.json new file mode 100644 index 0000000..a3662d2 --- /dev/null +++ b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/119.json @@ -0,0 +1,763 @@ +{ + "formatVersion": 1, + "database": { + "version": 119, + "identityHash": "9d13a3b687158704e0dce736e6b0e8dd", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `currency` TEXT, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `includeInBalance` INTEGER NOT NULL, `seAccountId` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "includeInBalance", + "columnName": "includeInBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seAccountId", + "columnName": "seAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `toAmount` REAL, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `recurringRuleId` TEXT, `attachmentUrl` TEXT, `loanId` TEXT, `seTransactionId` TEXT, `seAutoCategoryId` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "toAmount", + "columnName": "toAmount", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recurringRuleId", + "columnName": "recurringRuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loanId", + "columnName": "loanId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "seTransactionId", + "columnName": "seTransactionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "seAutoCategoryId", + "columnName": "seAutoCategoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `seCategoryName` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "seCategoryName", + "columnName": "seCategoryName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `accountId` TEXT NOT NULL, `categoryId` TEXT, `description` TEXT, `plannedDateTime` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "plannedDateTime", + "columnName": "plannedDateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "planned_payment_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`startDate` INTEGER, `intervalN` INTEGER, `intervalType` TEXT, `oneTime` INTEGER NOT NULL, `type` TEXT NOT NULL, `accountId` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryId` TEXT, `title` TEXT, `description` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalN", + "columnName": "intervalN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalType", + "columnName": "intervalType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "oneTime", + "columnName": "oneTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `authProviderType` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT, `profilePicture` TEXT, `color` INTEGER NOT NULL, `testUser` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authProviderType", + "columnName": "authProviderType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "profilePicture", + "columnName": "profilePicture", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "testUser", + "columnName": "testUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "baseCurrency", + "currency" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "budgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryIdsSerialized` TEXT, `accountIdsSerialized` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `orderId` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryIdsSerialized", + "columnName": "categoryIdsSerialized", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountIdsSerialized", + "columnName": "accountIdsSerialized", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderId", + "columnName": "orderId", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "loans", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `amount` REAL NOT NULL, `type` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "loan_records", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`loanId` TEXT NOT NULL, `amount` REAL NOT NULL, `note` TEXT, `dateTime` INTEGER NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "loanId", + "columnName": "loanId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9d13a3b687158704e0dce736e6b0e8dd')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/120.json b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/120.json new file mode 100644 index 0000000..84070e1 --- /dev/null +++ b/app/schemas/com.ivy.wallet.persistence.IvyRoomDatabase/120.json @@ -0,0 +1,793 @@ +{ + "formatVersion": 1, + "database": { + "version": 120, + "identityHash": "751c82ed72a54493f42000cd47a99137", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `currency` TEXT, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `includeInBalance` INTEGER NOT NULL, `seAccountId` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "includeInBalance", + "columnName": "includeInBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seAccountId", + "columnName": "seAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `type` TEXT NOT NULL, `amount` REAL NOT NULL, `toAccountId` TEXT, `toAmount` REAL, `title` TEXT, `description` TEXT, `dateTime` INTEGER, `categoryId` TEXT, `dueDate` INTEGER, `recurringRuleId` TEXT, `attachmentUrl` TEXT, `loanId` TEXT, `loanRecordId` TEXT, `seTransactionId` TEXT, `seAutoCategoryId` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "toAccountId", + "columnName": "toAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "toAmount", + "columnName": "toAmount", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recurringRuleId", + "columnName": "recurringRuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentUrl", + "columnName": "attachmentUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loanId", + "columnName": "loanId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "loanRecordId", + "columnName": "loanRecordId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "seTransactionId", + "columnName": "seTransactionId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "seAutoCategoryId", + "columnName": "seAutoCategoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `seCategoryName` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "seCategoryName", + "columnName": "seCategoryName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "wishlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `price` REAL NOT NULL, `accountId` TEXT NOT NULL, `categoryId` TEXT, `description` TEXT, `plannedDateTime` INTEGER, `orderNum` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "plannedDateTime", + "columnName": "plannedDateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`theme` TEXT NOT NULL, `currency` TEXT NOT NULL, `bufferAmount` REAL NOT NULL, `name` TEXT NOT NULL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "theme", + "columnName": "theme", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bufferAmount", + "columnName": "bufferAmount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "planned_payment_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`startDate` INTEGER, `intervalN` INTEGER, `intervalType` TEXT, `oneTime` INTEGER NOT NULL, `type` TEXT NOT NULL, `accountId` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryId` TEXT, `title` TEXT, `description` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalN", + "columnName": "intervalN", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "intervalType", + "columnName": "intervalType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "oneTime", + "columnName": "oneTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`email` TEXT NOT NULL, `authProviderType` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT, `profilePicture` TEXT, `color` INTEGER NOT NULL, `testUser` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authProviderType", + "columnName": "authProviderType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "profilePicture", + "columnName": "profilePicture", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "testUser", + "columnName": "testUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "baseCurrency", + "currency" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "budgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `amount` REAL NOT NULL, `categoryIdsSerialized` TEXT, `accountIdsSerialized` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `orderId` REAL NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "categoryIdsSerialized", + "columnName": "categoryIdsSerialized", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountIdsSerialized", + "columnName": "accountIdsSerialized", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderId", + "columnName": "orderId", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "loans", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `amount` REAL NOT NULL, `type` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `accountId` TEXT, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "loan_records", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`loanId` TEXT NOT NULL, `amount` REAL NOT NULL, `note` TEXT, `dateTime` INTEGER NOT NULL, `interest` INTEGER NOT NULL, `accountId` TEXT, `convertedAmount` REAL, `isSynced` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "loanId", + "columnName": "loanId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateTime", + "columnName": "dateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "interest", + "columnName": "interest", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "convertedAmount", + "columnName": "convertedAmount", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "isSynced", + "columnName": "isSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '751c82ed72a54493f42000cd47a99137')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..baf70ed --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..d778375 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/ivy/wallet/AppModuleDI.kt b/app/src/main/java/com/ivy/wallet/AppModuleDI.kt new file mode 100644 index 0000000..9fc6a71 --- /dev/null +++ b/app/src/main/java/com/ivy/wallet/AppModuleDI.kt @@ -0,0 +1,29 @@ +package com.ivy.wallet + +import android.content.Context +import com.ivy.billing.IvyBilling +import com.ivy.notifications.NotificationService +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) +object AppModuleDI { + @Provides + @Singleton + fun provideIvyBilling( + ): IvyBilling { + return IvyBilling() + } + + @Provides + fun provideNotificationService( + @ApplicationContext appContext: Context + ): NotificationService { + return NotificationService(appContext) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/IvyAndroidApp.kt b/app/src/main/java/com/ivy/wallet/IvyAndroidApp.kt new file mode 100644 index 0000000..ea300cf --- /dev/null +++ b/app/src/main/java/com/ivy/wallet/IvyAndroidApp.kt @@ -0,0 +1,44 @@ +package com.ivy.wallet + +import android.annotation.SuppressLint +import android.app.Application +import android.content.Context +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration +import com.ivy.common.BuildConfig +import com.ivy.core.ui.GlobalProvider +import dagger.hilt.android.HiltAndroidApp +import timber.log.Timber +import timber.log.Timber.DebugTree +import javax.inject.Inject + +/** + * Created by iliyan on 24.02.18. + */ +@HiltAndroidApp +class IvyAndroidApp : Application(), Configuration.Provider { + companion object { + @SuppressLint("StaticFieldLeak") + lateinit var appContext: Context + } + + + @Inject + lateinit var workerFactory: HiltWorkerFactory + + override fun getWorkManagerConfiguration(): Configuration { + return Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() + } + + override fun onCreate() { + super.onCreate() + appContext = this + GlobalProvider.appContext = this + + if (BuildConfig.DEBUG) { + Timber.plant(DebugTree()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/ui/RootActivity.kt b/app/src/main/java/com/ivy/wallet/ui/RootActivity.kt new file mode 100644 index 0000000..a0d4c11 --- /dev/null +++ b/app/src/main/java/com/ivy/wallet/ui/RootActivity.kt @@ -0,0 +1,416 @@ +package com.ivy.wallet.ui + +import android.app.DatePickerDialog +import android.app.TimePickerDialog +import android.appwidget.AppWidgetManager +import android.content.ActivityNotFoundException +import android.content.ComponentName +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.text.format.DateFormat +import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.BoxWithConstraintsScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.ivy.exchangeRates.ExchangeRatesScreen +import com.google.android.play.core.review.ReviewManagerFactory +import com.ivy.accounts.AccountsScreen +import com.ivy.api.screen.backup.ImportBackupScreen +import com.ivy.categories.CategoriesScreen +import com.ivy.common.Constants +import com.ivy.common.Constants.SUPPORT_EMAIL +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.timeNow +import com.ivy.common.time.toEpochMilli +import com.ivy.core.ui.RootScreen +import com.ivy.core.ui.Toaster +import com.ivy.data.file.FileType +import com.ivy.debug.TestScreen +import com.ivy.design.api.IvyUI +import com.ivy.design.api.setAppDesign +import com.ivy.design.api.systems.ivyWalletDesign +import com.ivy.drive.google_drive.api.GoogleDriveConnection +import com.ivy.file.CreateFileLauncher +import com.ivy.file.FilePickerLauncher +import com.ivy.home.HomeScreen +import com.ivy.menu.HomeMoreMenuScreen +import com.ivy.navigation.NavigationRoot +import com.ivy.navigation.Navigator +import com.ivy.navigation.graph.DebugScreens +import com.ivy.navigation.graph.OnboardingScreens +import com.ivy.navigation.graph.TransactionScreens +import com.ivy.onboarding.screen.debug.OnboardingDebug +import com.ivy.photo.frame.AddFrameScreen +import com.ivy.resources.R +import com.ivy.settings.SettingsScreen +import com.ivy.transaction.create.transfer.NewTransferScreen +import com.ivy.transaction.create.trn.NewTransactionScreen +import com.ivy.transaction.edit.transfer.EditTransferScreen +import com.ivy.transaction.edit.trn.EditTransactionScreen +import com.ivy.wallet.BuildConfig +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import timber.log.Timber +import java.time.LocalDate +import java.time.LocalTime +import java.util.* +import javax.inject.Inject + +@AndroidEntryPoint +class RootActivity : AppCompatActivity(), RootScreen { + companion object { + const val SHORTCUT_ACTION = "ivy.wallet.intent.action.add_transaction" + } + + @Inject + lateinit var navigator: Navigator + + @Inject + lateinit var timeProvider: TimeProvider + + @Inject + lateinit var filePickerLauncher: FilePickerLauncher + + @Inject + lateinit var createFileLauncher: CreateFileLauncher + + @Inject + lateinit var googleDriveConnection: GoogleDriveConnection + + @Inject + lateinit var toaster: Toaster + + + private val viewModel: RootViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // region setup ActivityForResult launchers + filePickerLauncher.wire(this) + createFileLauncher.wire(this) + googleDriveConnection.wire(this) + // endregion + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + toaster.messages.collectLatest { + Toast.makeText(this@RootActivity, it, Toast.LENGTH_LONG) + .show() + } + } + } + + // Check if the intent was triggered by a shortcut action, and notify the view model of the shortcut click event + if (intent.action == SHORTCUT_ACTION) { + viewModel.onEvent(RootEvent.ShortcutClick(intent)) + } + + // Make the app drawing area fullscreen (draw behind status and nav bars) + WindowCompat.setDecorFitsSystemWindows(window, false) + + setContent { + val viewModel: RootViewModel = hiltViewModel() + val state by viewModel.uiState.collectAsState() + val isSystemInDarkTheme = isSystemInDarkTheme() + + LaunchedEffect(state.theme, isSystemInDarkTheme) { + setAppDesign( + ivyWalletDesign( + theme = state.theme, + isSystemInDarkTheme = isSystemInDarkTheme + ) + ) + } + + IvyUI { + NavigationRoot(state) + } + } + + viewModel.onEvent(RootEvent.AppOpen) + } + + @Composable + private fun BoxWithConstraintsScope.NavigationRoot(state: RootState) { + NavigationRoot( + navigator = navigator, + onboardingScreens = OnboardingScreens( + debug = { OnboardingDebug() }, + loginOrOffline = {}, + importBackup = {}, + setCurrency = {}, + addAccounts = {}, + addCategories = {} + ), + home = { HomeScreen() }, + accounts = { AccountsScreen() }, + moreMenu = { HomeMoreMenuScreen() }, + categories = { CategoriesScreen() }, + settings = { SettingsScreen() }, + transactionScreens = TransactionScreens( + accountTransactions = {}, + categoryTransactions = {}, + newTransaction = { NewTransactionScreen(arg = it) }, + newTransfer = { NewTransferScreen() }, + transaction = { EditTransactionScreen(trnId = it) }, + transfer = { EditTransferScreen(batchId = it) } + ), + addFrame = { AddFrameScreen() }, + importBackup = { ImportBackupScreen() }, + exchangeRates = { ExchangeRatesScreen() }, + debugScreens = DebugScreens( + test = { TestScreen() } + ) + ) + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) +// if (viewModel.isAppLockEnabled() && !hasFocus) { +// window.setFlags( +// WindowManager.LayoutParams.FLAG_SECURE, +// WindowManager.LayoutParams.FLAG_SECURE +// ) +// } else { +// window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) +// } + } + + override fun onResume() { + super.onResume() +// if (viewModel.isAppLockEnabled()) +// viewModel.checkUserInactiveTimeStatus() + } + + override fun onPause() { + super.onPause() +// if (viewModel.isAppLockEnabled()) +// viewModel.startUserInactiveTimeCounter() + } + + private fun authenticateWithOSBiometricsModal( + biometricPromptCallback: BiometricPrompt.AuthenticationCallback + ) { + val executor = ContextCompat.getMainExecutor(this) + val biometricPrompt = BiometricPrompt( + this, executor, + biometricPromptCallback + ) + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle( + getString(R.string.authentication_required) + ) + .setSubtitle( + getString(R.string.authentication_required_description) + ) + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_WEAK or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + ) + .setConfirmationRequired(false) + .build() + + biometricPrompt.authenticate(promptInfo) + } + + // region Helpers for Compose UI + fun contactSupport() { + val caseNumber: Int = Random().nextInt(100) + 100 + + val emailIntent = Intent(Intent.ACTION_SENDTO).apply { + data = Uri.parse("mailto:") // only email apps should handle this + + putExtra(Intent.EXTRA_EMAIL, arrayOf(SUPPORT_EMAIL)) + putExtra( + Intent.EXTRA_SUBJECT, "Ivy Wallet Support Request #" + caseNumber + + "0" + BuildConfig.VERSION_CODE + ) + putExtra(Intent.EXTRA_TEXT, "") + } + + try { + startActivity(emailIntent) + + } catch (e: Exception) { + e.printStackTrace() + Toast.makeText(this, "Email: $SUPPORT_EMAIL", Toast.LENGTH_LONG).show() + } + } + + override fun openUrlInBrowser(url: String) { + try { + val browserIntent = Intent(Intent.ACTION_VIEW) + browserIntent.data = Uri.parse(url) + startActivity(browserIntent) + } catch (e: Exception) { + e.printStackTrace() + Toast.makeText( + this, + "No browser app found. Visit manually: $url", + Toast.LENGTH_LONG + ).show() + } + } + + override fun fileChooser(fileType: FileType, onFileChosen: (Uri) -> Unit) { + filePickerLauncher.launch(fileType) { + it?.let(onFileChosen) + } + } + + override fun createFile(fileName: String, onFileCreated: (Uri) -> Unit) { + createFileLauncher.launch(fileName) { + it?.let(onFileCreated) + } + } + + override fun shareIvyWallet() { + val share = Intent.createChooser( + Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, Constants.URL_IVY_WALLET_GOOGLE_PLAY) + type = "text/plain" + }, + null + ) + startActivity(share) + } + + fun openIvyWalletGooglePlayPage() { + openGooglePlayAppPage(appId = Constants.IVY_WALLET_APP_ID) + } + + override fun openGooglePlayAppPage(appId: String) { + try { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$appId"))) + } catch (e: ActivityNotFoundException) { + startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse("https://play.google.com/store/apps/details?id=$appId") + ) + ) + } + } + + override fun shareCSVFile(fileUri: Uri) { + val intent = Intent.createChooser( + Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_STREAM, fileUri) + type = "text/csv" + }, null + ) + startActivity(intent) + } + + override fun shareZipFile(fileUri: Uri) { + val intent = Intent.createChooser( + Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_STREAM, fileUri) + type = "application/zip" + }, null + ) + startActivity(intent) + } + + override fun reviewIvyWallet(dismissReviewCard: Boolean) { + val manager = ReviewManagerFactory.create(this) + val request = manager.requestReviewFlow() + request.addOnCompleteListener { task -> + if (task.isSuccessful) { + // We got the ReviewInfo object + val reviewInfo = task.result + val flow = manager.launchReviewFlow(this, reviewInfo) + flow.addOnCompleteListener { + // The flow has finished. The API does not indicate whether the user + // reviewed or not, or even whether the review dialog was shown. Thus, no + // matter the result, we continue our app flow. + if (dismissReviewCard) { +// customerJourneyLogic.dismissCard(CustomerJourneyLogic.rateUsCard()) + } + + openIvyWalletGooglePlayPage() + } + } else { + openIvyWalletGooglePlayPage() + } + } + } + + override fun pinWidget(widget: Class) { + val appWidgetManager: AppWidgetManager = this.getSystemService(AppWidgetManager::class.java) + val addTransactionWidget = ComponentName(this, widget) + appWidgetManager.requestPinAppWidget(addTransactionWidget, null, null) + } + + // region Date Picker + override fun datePicker( + minDate: LocalDate?, + maxDate: LocalDate?, + initialDate: LocalDate?, + onDatePicked: (LocalDate) -> Unit + ) { + val picker = DatePickerDialog(this) + + if (minDate != null) { + picker.datePicker.minDate = minDate.atTime(12, 0) + .toEpochMilli(timeProvider) + } + + if (maxDate != null) { + picker.datePicker.maxDate = maxDate.atTime(12, 0) + .toEpochMilli(timeProvider) + } + + picker.setOnDateSetListener { _, year, month, dayOfMonth -> + Timber.i("Date picked: $year year $month month day $dayOfMonth") + onDatePicked(LocalDate.of(year, month + 1, dayOfMonth)) + } + picker.show() + + if (initialDate != null) { + picker.updateDate( + initialDate.year, + //month - 1 because LocalDate start from 1 and date picker starts from 0 + initialDate.monthValue - 1, + initialDate.dayOfMonth + ) + } + } + // endregion + + // region Time Picker + override fun timePicker(onTimePicked: (LocalTime) -> Unit) { + val nowLocal = timeNow() + val picker = TimePickerDialog( + this, + { _, hourOfDay, minute -> + onTimePicked(LocalTime.of(hourOfDay, minute).withSecond(0)) + }, + nowLocal.hour, nowLocal.minute, DateFormat.is24HourFormat(this) + ) + picker.show() + } + // endregion + // endregion +} \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/ui/RootEvent.kt b/app/src/main/java/com/ivy/wallet/ui/RootEvent.kt new file mode 100644 index 0000000..ae5a15a --- /dev/null +++ b/app/src/main/java/com/ivy/wallet/ui/RootEvent.kt @@ -0,0 +1,9 @@ +package com.ivy.wallet.ui + +import android.content.Intent + +sealed interface RootEvent { + object AppOpen : RootEvent + data class ShortcutClick(val intent: Intent) : RootEvent + +} \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/ui/RootState.kt b/app/src/main/java/com/ivy/wallet/ui/RootState.kt new file mode 100644 index 0000000..93c93be --- /dev/null +++ b/app/src/main/java/com/ivy/wallet/ui/RootState.kt @@ -0,0 +1,10 @@ +package com.ivy.wallet.ui + +import androidx.compose.runtime.Immutable +import com.ivy.data.Theme + +@Immutable +data class RootState( + val appLocked: Boolean, + val theme: Theme, +) \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/ui/RootViewModel.kt b/app/src/main/java/com/ivy/wallet/ui/RootViewModel.kt new file mode 100644 index 0000000..63827ad --- /dev/null +++ b/app/src/main/java/com/ivy/wallet/ui/RootViewModel.kt @@ -0,0 +1,115 @@ +package com.ivy.wallet.ui + +import android.content.Intent +import com.ivy.common.isNotEmpty +import com.ivy.core.domain.FlowViewModel +import com.ivy.core.domain.action.exchange.SyncExchangeRatesAct +import com.ivy.core.domain.action.settings.basecurrency.BaseCurrencyFlow +import com.ivy.core.domain.action.settings.theme.ThemeFlow +import com.ivy.data.CurrencyCode +import com.ivy.data.Theme +import com.ivy.data.transaction.TransactionType +import com.ivy.drive.google_drive.api.GoogleDriveConnection +import com.ivy.navigation.Navigator +import com.ivy.navigation.destinations.Destination +import com.ivy.navigation.destinations.transaction.NewTransaction +import com.ivy.onboarding.action.OnboardingFinishedAct +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class RootViewModel @Inject constructor( + private val onboardingFinishedAct: OnboardingFinishedAct, + private val navigator: Navigator, + private val syncExchangeRatesAct: SyncExchangeRatesAct, + baseCurrencyFlow: BaseCurrencyFlow, + private val themeFlow: ThemeFlow, + private val googleDriveConnection: GoogleDriveConnection +) : FlowViewModel() { + companion object { + const val EXTRA_ADD_TRANSACTION_TYPE = "add_transaction_type_extra" + } + + override val initialState = InternalState(baseCurrency = "") + + override val stateFlow: Flow = baseCurrencyFlow().map { baseCurrency -> + if (baseCurrency.isNotEmpty()) { + Timber.i("Syncing exchange rates for $baseCurrency") + syncExchangeRatesAct(baseCurrency) + } + InternalState(baseCurrency = baseCurrency) + } + + override val initialUi = RootState(appLocked = false, theme = Theme.Auto) + + override val uiFlow: Flow = themeFlow(Unit).map { theme -> + RootState( + appLocked = false, + theme = theme + ) + } + + + // region Event Handling + override suspend fun handleEvent(event: RootEvent) { + when (event) { + is RootEvent.AppOpen -> { + handleAppOpen() + } + + is RootEvent.ShortcutClick -> { + handleShortcut(event.intent) + } + } + } + + private suspend fun handleAppOpen() { + if (!onboardingFinishedAct(Unit)) { + delay(300) // TODO: Fix that + // navigate to Onboarding + navigator.navigate(Destination.onboarding.route) { + popUpTo(Destination.home.route) { + inclusive = true + } + } + } + + googleDriveConnection.mount() + } + + //function to handle shortcut action clicks + private fun handleShortcut(intent: Intent) { + when (intent.getStringExtra(EXTRA_ADD_TRANSACTION_TYPE)) { + // Add expense shortcut + "EXPENSE" -> { + navigator.navigate( + Destination.newTransaction.destination( + NewTransaction.Arg(trnType = TransactionType.Expense) + ) + ) + } + // Add income shortcut + "INCOME" -> { + navigator.navigate( + Destination.newTransaction.destination( + NewTransaction.Arg(trnType = TransactionType.Income) + ) + ) + } + // Add transfer shortcut + "TRANSFER" -> { + navigator.navigate(Destination.newTransfer.destination(Unit)) + } + } + } + + // endregion + + data class InternalState( + val baseCurrency: CurrencyCode, + ) +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..912bce5 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..31dab5b --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..f30783b --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..f30783b --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..03eeaab Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..989257c Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..d83b781 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..d2a33c8 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..f8a936f Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..d42a161 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..ab8953d Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b7f4d83 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..cad056a Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..1f27970 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c37268d --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #E6FFEE + \ No newline at end of file diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 0000000..4246244 --- /dev/null +++ b/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..8f7c924 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,26 @@ +# Assets + +Diagrams created using [D2 (Declarative Diagrams)](https://github.com/terrastruct/d2) lang and other assets. + +## D2 Lang + +### Install + +Install via: +``` +curl -fsSL https://d2lang.com/install.sh | sh -s -- +``` + +Or copy better copy the latest instructions from the [D2 repo](https://github.com/terrastruct/d2). + + +### Build + +To build & update a diagram use: +``` +d2 --watch diagram_file.d2 +``` + +## Guidelines + +When adding D2 diagrams in Ivy Wallet's repo make sure that in `.d2` file and out `.svg` have the same name. \ No newline at end of file diff --git a/assets/account_cache_algo.d2 b/assets/account_cache_algo.d2 new file mode 100644 index 0000000..1ee45eb --- /dev/null +++ b/assets/account_cache_algo.d2 @@ -0,0 +1,88 @@ + +RawAccStatsFlow { + deps: Dependencies { + shape: class + calcTrnDao: CalcTrnDao + rawStatsFlow: RawStatsFlow + accountsCacheDao: AccountCacheDao + } + + in: Input { + shape: class + accountId: String + } + + 1: "Lookup for cache by `account_id`. Cache found?" + 1_true: "Cache found." { + 1: "Fetch ONLY the transactions after the cache time for the account." { + fill: green + font-color: white + } + 2: "Execute RawStatsFlow only for the transactions after the cache." { + fill: green + font-color: white + } + 3: "Sum the cached `RawStats` with the just calculated ones." + 4: "Update the cache." { + shape: cylinder + fill: blue + font-color: white + } + 5: "Return the summed `RawStats`" + 1 -> 2 -> 3 -> 4 -> 5 + } + 1_false: "Cache NOT found." { + 1: "Fetch ALL transactions for the account." { + shape: cylinder + fill: orange + font-color: white + } + 2: "Execute RawStasFlow for all of account's transactions." { + fill: orange + font-color: white + } + 3: "Update the cache." { + shape: cylinder + fill: blue + font-color: white + } + 4: "Return the calculated `RawStats`" + + 1 -> 2 -> 3 -> 4 + } + + out: RawStats { + shape: class + incomes: Map + expenses: Map + incomesCount: Int + expensesCount: Int + } + + in -> 1: accountId + deps.accountsCacheDao -> 1: accountsCacheDao + deps.calcTrnDao -> 1_false: calcTrnDao + deps.rawStatsFlow -> 1_false: rawStatsFlow + 1 -> 1_true: True + 1 -> 1_false: False + 1_true.5 -> out: Cache (from DB) + 1_false.4 -> out: Processed (slow) +} + +db.accounts_cache -> \ + RawAccStatsFlow.deps.accountsCacheDao: AccountsCacheDao + +db: Database { + shape: cylinder + + accounts_cache: { + shape: sql_table + + account_id: String { constraint: primary_key } + incomes_json: "JSON [{\"curr\":\"str\",\"amount\":0.0}]" + expenses: "JSON [{\"curr\":\"str\",\"amount\":0.0}]" + incomes_count: Int + expenses_count: Int + } +} + diff --git a/assets/account_cache_algo.svg b/assets/account_cache_algo.svg new file mode 100644 index 0000000..27782af --- /dev/null +++ b/assets/account_cache_algo.svg @@ -0,0 +1,107 @@ + +RawAccStatsFlowDatabaseDependencies+ +calcTrnDao +CalcTrnDao+ +rawStatsFlow +RawStatsFlow+ +accountsCacheDao +AccountCacheDaoInput+ +accountId +StringLookup for cache by `account_id`. Cache found?Cache found.Cache NOT found.RawStats+ +incomes +Map<CurrencyCode, Double>+ +expenses +Map<CurrencyCode, Double>+ +incomesCount +Int+ +expensesCount +Intaccounts_cacheaccount_id +String +PKincomes_json +JSON [{"curr":"str","amount":0.0}] +expenses +JSON [{"curr":"str","amount":0.0}] +incomes_count +Int +expenses_count +Int +Fetch ONLY the transactions after the cache time for the account.Execute RawStatsFlow only for the transactions after the cache.Sum the cached `RawStats` with the just calculated ones.Update the cache.Return the summed `RawStats`Fetch ALL transactions for the account.Execute RawStasFlow for all of account's transactions.Update the cache.Return the calculated `RawStats` accountIdtruefalseCache (from DB)Processed (slow)accountsCacheDaocalcTrnDaorawStatsFlowAccountsCacheDao + + + + + + + + + + + diff --git a/assets/account_cache_invalidate_algo.d2 b/assets/account_cache_invalidate_algo.d2 new file mode 100644 index 0000000..0d95ceb --- /dev/null +++ b/assets/account_cache_invalidate_algo.d2 @@ -0,0 +1,87 @@ +WriteAccountsAct { + del: "Deletie account" +} + +WriteTrnsAct { + del: "Delete transaction" + create: "Create transaction" + update: "Update transaction" +} + +WriteAccountsAct.del -> UpdateAccCacheAct.onAccDel: Triggers +WriteTrnsAct.del -> UpdateAccCacheAct.onTrnUpdate.in.del: Triggers +WriteTrnsAct.create -> UpdateAccCacheAct.onTrnUpdate.in.cr: Triggers +WriteTrnsAct.update -> UpdateAccCacheAct.onTrnUpdate.in.upd: Triggers + +UpdateAccCacheAct { + onAccDel: "On: Account deleted" { + op: "Invalidate the cache for that account cuz it's deleted." { + fill: red + font-color: white + } + } + + onTrnUpdate: "On: Transaction updated/deleted" { + in: Input { + del: DeleteTrnInfo { + shape: class + time: TrnTime + } + + cr: CreateTrnInfo { + shape: class + time: TrnTime + } + + upd: UpdateTrnInfo { + shape: class + oldTime: TrnTime + time: TrnTime + } + } + read: "Cache entry in DB?" + read_null: "Do nothing." { + fill: green + font-color: white + } + read_exists: "Is the cache still valid?" { + a: "Is the updated transaction after the cache, in the future?" { + "trn.time > cache.time?" + } + b: "Was the updated transaction moved from the past to the future?" { + "trn.oldTime == null || trn.oldTime < cache.time?" + } + + a -> b: AND + } + trn_newer: "It's okay. The cache is valid - do nothing." { + fill: green + font-color: white + } + trn_older: "Delete!!! The cache is invalid!" { + fill: red + font-color: white + } + + in -> read + read --> read_null: "Nope" + read --> read_exists: "Found!" + + read_exists --> trn_newer: "True" + read_exists --> trn_older: "False" + } +} + +db: Database { + shape: cylinder + + accounts_cache: { + shape: sql_table + + account_id: String { constraint: primary_key } + other: Doesn't matter + } +} + +UpdateAccCacheAct.onAccDel.op -> db.accounts_cache: "Delete by account.id" +UpdateAccCacheAct.onTrnUpdate.trn_older --> db.accounts_cache: "Delete by trn.accountId" diff --git a/assets/account_cache_invalidate_algo.svg b/assets/account_cache_invalidate_algo.svg new file mode 100644 index 0000000..7956b40 --- /dev/null +++ b/assets/account_cache_invalidate_algo.svg @@ -0,0 +1,95 @@ + +WriteAccountsActWriteTrnsActUpdateAccCacheActDatabaseDeletie accountDelete transactionCreate transactionUpdate transactionOn: Account deletedOn: Transaction updated/deletedaccounts_cacheaccount_id +String +PKother +Doesn't matter +Invalidate the cache for that account cuz it's deleted.InputCache entry in DB?Do nothing.Is the cache still valid?It's okay. The cache is valid - do nothing.Delete!!! The cache is invalid!DeleteTrnInfo+ +time +TrnTimeCreateTrnInfo+ +time +TrnTimeUpdateTrnInfo+ +oldTime +TrnTime+ +time +TrnTimeIs the updated transaction after the cache, in the future?Was the updated transaction moved from the past to the future?trn.time > cache.time?trn.oldTime == null || trn.oldTime < cache.time? TriggersTriggersTriggersTriggersANDNopeFound!TrueFalseDelete by account.idDelete by trn.accountId + + + + + + + + + + + + + diff --git a/assets/architecture.d2 b/assets/architecture.d2 new file mode 100644 index 0000000..15c002a --- /dev/null +++ b/assets/architecture.d2 @@ -0,0 +1,198 @@ +direction: down + +feature: "Feature modules" { + ui: "Compose UI" { + "Usually a Screen" + "Destination in the Navigation graph" + } + state: "UI State" { + "Data class only of Compose primitives" + } + event: "Event" { + "ADT with all possible user interactions" + } + vm: "ViewModel" { + "Produces UI State" + "Handles Event" + } + + actions: "Actions (impure)" { + "Feature-level actions" + } + + ui -> state: "Latest state" + ui -> event: "User interactions" + state -> vm: "Produced by the ViewModel" + event -> vm: "Sent to the ViewModel" + vm -> actions: "Feauture-level logic" +} +feature.ui -> core-ui.uiComponents: "@Composables" +feature.actions -> core-ui.barrier: "Read-only Flows (RX) + Calculations (pure)" +feature.actions -> core-domain.barrier: "Write Actions (impure) + Reading Snapshots (impure)" + +core-ui: ":core:ui" { + barrier: Abstraction Barrier { + fill: "#f0ff3a" + "Read Actions (impure)" { + "Flow (RX)" + } + "Calculations (pure)" { + "E.g. format: Value -> Boolean -> ValueUi" + } + } + + uiComponents: "UI Components: (impure)" { + fill: "#f0ff3a" + "Compose UI" { + "Common UI Components" + "Modals" + } + } + + + uiMap: "Map Domain to UI data" { + "Data -> DataUi (e.g. Transaction -> TransactionUi)" + } + + barrier -> uiMap + uiComponents -> uiMap +} +core-ui.uiMap -> core-domain.barrier: "Read-only Flows (RX)" + + +core-domain: ":core:domain" { + barrier: Abstraction Barrier (exposed) { + fill: "#f0ff3a" + "Write Actions (impure)" { + suspend + } + read: "Read Actions (impure)" { + "Flow (RX)" + "suspend (snapshot)" + } + } + + actions: "Actions (impure)" { + "Provide DI" + "Execute Effects" + } + + calculations: "Caculations" { + "Pure processing layer" + } + + actions -> calculations: "Domain logic" + barrier -> actions +} +core-domain.actions -> core-persistence.barrier: "Write/Read from persistence as Domain" +core-domain.actions -> core-exchange-provider.barrier: "Fetch remote exchange rates" + +core-exchange-provider: ":core:exchange-provider" { + barrier: "Abstraction Barrier (exposed)" { + fill: "#f0ff3a" + RemoteExchangeProvider { + "suspend fun fetchExchangeRates(baseCurrency: CurrencyCode): Result" + } + } + + provider: Provider { + effect: "Fetches remote exchange rates for a base-currency" + calculation: "Calculations" { + "Filter only valid rates" + "Convert to base currency" + } + + effect -> calculation + } + + barrier -> provider +} + +core-persistence: ":core:persistence" { + barrier: "Abstraction Barrier (exposed)" { + fill: "#f0ff3a" + read: "Read Actions (impure)" { + "Flow (RX)" + "suspend (snapshot)" + } + write: "Write Actions (impure)" { + "suspend" + } + } + + actions: "Actions (impure)" { + dao: "DAOs" + store: "DataStore" + } + + validation: "Validation layer (pure)" { + "Ensures data being written is correct" + "Ensures data being read is correct" + } + + db: "Relational Database" { + sql: "SQL tables" { + shape: cylinder + } + } + + datastore: "DataStore" { + kvs: "Key-Value Storage" { + shape: cylinder + } + } + + barrier -> actions + actions.dao -> db + actions.store -> datastore + actions -> validation +} + +core-data-model { + fill: purple + font-color: white + data: "Domain data" { + "ADTs best describing the domain" + "example: Transaction" + } + optimized: "Optimzed" { + "Select only the needed parts of the domain data" + "example: TrnHistory" + "example: TrnCalc" + } +} +core-domain -> core-data-model: "Uses" +core-persistence -> core-data-model: "Uses" + + +# Other services +sync: ":sync" { + fetch: "Fetches Backup JSON from Google Drive" + merge: "Merges the data with the local DB" + upload: "Uploads data to Google Drive" +} +sync -> google-drive +sync -> backup + +backup: ":backup" { + "Export backup JSON" + "Import backup JSON" + "Import old backup JSON" +} +backup -> core-domain.barrier: "Read data snapshot / Write to persistence" + +google-drive: ":drive:google-drive" { + "GoogleDriveService: get & upload data to drive" +} + +sms-parser: "automate:sms-parser" { + parse: "Parses transactions from SMS" + auto: "Automatically adds valid transactions" +} +sms-parser -> core-domain.barrier + +notifications-parser: "automate:notification-parser" { + parse: "Parses transactions from Notificaitons" + auto: "Automatically adds valid transactions" +} +notifications-parser -> core-domain.barrier diff --git a/assets/architecture.svg b/assets/architecture.svg new file mode 100644 index 0000000..3dad15a --- /dev/null +++ b/assets/architecture.svg @@ -0,0 +1,80 @@ + +Feature modules:core:ui:core:domain:core:exchange-provider:core:persistencecore-data-model:sync:backup:drive:google-driveautomate:sms-parserautomate:notification-parserCompose UIUI StateEventViewModelActions (impure)Abstraction BarrierUI Components: (impure)Map Domain to UI dataAbstraction Barrier (exposed)Actions (impure)CaculationsAbstraction Barrier (exposed)ProviderAbstraction Barrier (exposed)Actions (impure)Validation layer (pure)Relational DatabaseDataStoreDomain dataOptimzedFetches Backup JSON from Google DriveMerges the data with the local DBUploads data to Google DriveExport backup JSONImport backup JSONImport old backup JSONGoogleDriveService: get & upload data to driveParses transactions from SMSAutomatically adds valid transactionsParses transactions from NotificaitonsAutomatically adds valid transactionsUsually a ScreenDestination in the Navigation graphData class only of Compose primitivesADT with all possible user interactionsProduces UI StateHandles EventFeature-level actionsRead Actions (impure)Calculations (pure)Compose UIData -> DataUi (e.g. Transaction -> TransactionUi)Write Actions (impure)Read Actions (impure)Provide DIExecute EffectsPure processing layerRemoteExchangeProviderFetches remote exchange rates for a base-currencyCalculationsRead Actions (impure)Write Actions (impure)DAOsDataStoreEnsures data being written is correctEnsures data being read is correctSQL tablesKey-Value StorageADTs best describing the domainexample: TransactionSelect only the needed parts of the domain dataexample: TrnHistoryexample: TrnCalcFlow (RX)E.g. format: Value -> Boolean -> ValueUiCommon UI ComponentsModalssuspendFlow (RX)suspend (snapshot)suspend fun fetchExchangeRates(baseCurrency: CurrencyCode): ResultFilter only valid ratesConvert to base currencyFlow (RX)suspend (snapshot)suspend Latest stateUser interactionsProduced by the ViewModelSent to the ViewModelFeauture-level logic@ComposablesRead-only Flows (RX) + Calculations (pure)Write Actions (impure) + Reading Snapshots (impure)Read-only Flows (RX)Domain logicWrite/Read from persistence as DomainFetch remote exchange ratesUsesUsesRead data snapshot / Write to persistence + + + + + + + + + + + + + + + + + diff --git a/assets/calc_algo.d2 b/assets/calc_algo.d2 new file mode 100644 index 0000000..d457ff3 --- /dev/null +++ b/assets/calc_algo.d2 @@ -0,0 +1,150 @@ + +cTrnDao: CalcTrnDao { + q: Query { + ByTime + ByCategory + ByAccount + ByPurpose + } + sql: "SQL: O(trns.count) time | O(trns.notDel.count) space" { + "SELECT amount, currency, type FROM transactions WHERE ..." + } + trns: "List" { + CalcTrn { + shape: class + + amount: Double + currency: String + type: TransactionType + } + } + + q -> sql -> trns +} + +rawStatsFlow: RawStatsFlow { + in: Input { + shape: class + trns: List + } + + p: "Process: O(trns.count) time | O(currs.unique.count) space" { + "trns.forEach { aggregate incomes, expense by currencies + count them }" + } + + out: RawStats { + shape: class + incomes: Map + expenses: Map + incomesCount: Int + expensesCount: Int + } + + in -> p -> out +} + +cTrndao.trns -> rawStatsFlow.in.trns + +# RatesFlow +ratesDao: RatesDao { + sql: "SQL: O(rates.count) time | O(rates.baseCurr.count) space" { + "SELECT rate, currency FROM exchange_rates WHERE baseCurrency = ?" + } + out: "List" { + Rate { + shape: class + rate: Double + currency: String + } + } + sql -> out +} + +ratesOverrideDao: RateOverrideDao { + sql: "SQL: O(rates.override.count) time | O(rates.override.baseCurr.count) space" { + "SELECT rate, currency FROM exchange_rates_override WHERE baseCurrency = ? AND sync != $DELETING" + } + out: "List" { + Rate { + shape: class + rate: Double + currency: String + } + } + sql -> out +} + +ratesFlow: RatesFlow { + deps: Dependencies { + ratesDao + ratesOverrideDao + baseCurrencyFlow + } + p: "Process: O(rates.override.count) time | O(1) space" { + 1: "baseCurrency.flatMapLatest {}" + 2: "combine(rateDao.findByBaseCurr(), ratesOverridedao.findByBaseCurr())" + 3: "Override rate with the manual set ones" + + 1 -> 2 -> 3 + + } + out: "RatesData" { + shape: class + baseCurrency: String + rates: Map + } + + deps.ratesDao -> p: Reacts + deps.ratesOverrideDao -> p: Reacts + deps.baseCurrencyFlow -> p: Reacts + p -> out +} + +ratesDao -> ratesFlow.deps +ratesOverrideDao -> ratesFlow.deps + + +# ExchangeStatsFlow +exFlow: ExchangeStatsFlow { + deps: Dependencies { + ratesFlow: "rates: RatesFlow" + } + + in: Input { + shape: class + rawStats: RawStats + outputCurrency: String + } + + p: "Process: O(curr.unique.count) space-time" { + incs_loop: "rawStats.incomes.forEach {}" + incs_exchange: "exchange to output currency" + incs_sum: "sum & count" + + incs_loop -> incs_exchange -> incs_sum + + exps_loop: "rawStats.expenses.forEach {}" + exps_exchange: "exchange to output currency" + exps_sum: "sum & count" + + exps_loop -> exps_exchange -> exps_sum + } + + out: Stats { + shape: class + income: Value + expense: Value + incomesCount: Int + expensesCount: Int + } + + deps.ratesFlow -> p: Reacts to rates changes + in.rawStats -> p + p.incs_sum -> out + p.exps_sum -> out +} + +ratesFlow.out -> exFlow.deps.ratesFlow: Reacts +rawStatsFlow.out -> exFlow.in.rawStats + + diff --git a/assets/calc_algo.svg b/assets/calc_algo.svg new file mode 100644 index 0000000..5f10c55 --- /dev/null +++ b/assets/calc_algo.svg @@ -0,0 +1,117 @@ + +CalcTrnDaoRawStatsFlowRatesDaoRateOverrideDaoRatesFlowExchangeStatsFlowQuerySQL: O(trns.count) time | O(trns.notDel.count) spaceList<CalcTrn>Input+ +trns +List<CalcTrn>Process: O(trns.count) time | O(currs.unique.count) spaceRawStats+ +incomes +Map<CurrencyCode, Double>+ +expenses +Map<CurrencyCode, Double>+ +incomesCount +Int+ +expensesCount +IntSQL: O(rates.count) time | O(rates.baseCurr.count) spaceList<Rate>SQL: O(rates.override.count) time | O(rates.override.baseCurr.count) spaceList<Rate>DependenciesProcess: O(rates.override.count) time | O(1) spaceRatesData+ +baseCurrency +String+ +rates +Map<CurrencyCode, Double>DependenciesInput+ +rawStats +RawStats+ +outputCurrency +StringProcess: O(curr.unique.count) space-timeStats+ +income +Value+ +expense +Value+ +incomesCount +Int+ +expensesCount +IntByTimeByCategoryByAccountByPurposeSELECT amount, currency, type FROM transactions WHERE ...CalcTrn+ +amount +Double+ +currency +String+ +type +TransactionTypetrns.forEach { aggregate incomes, expense by currencies + count them }SELECT rate, currency FROM exchange_rates WHERE baseCurrency = ?Rate+ +rate +Double+ +currency +StringSELECT rate, currency FROM exchange_rates_override WHERE baseCurrency = ? AND sync != $DELETINGRate+ +rate +Double+ +currency +StringratesDaoratesOverrideDaobaseCurrencyFlowbaseCurrency.flatMapLatest {}combine(rateDao.findByBaseCurr(), ratesOverridedao.findByBaseCurr())Override rate with the manual set onesrates: RatesFlowrawStats.incomes.forEach {}exchange to output currencysum & countrawStats.expenses.forEach {}exchange to output currencysum & count ReactsReactsReactsReacts to rates changesReacts + + + + + + + diff --git a/assets/database_schema.d2 b/assets/database_schema.d2 new file mode 100644 index 0000000..c74bd1c --- /dev/null +++ b/assets/database_schema.d2 @@ -0,0 +1,207 @@ +# Accounts +accounts { + shape: sql_table + id: uuid {constraint: primary_key} + name: "string" + icon_id: "string" + color_int: "int" + asset_code: "string" + order_num: "double" + archived: "boolean" + include_in_balance: "boolean" + type: "int: Asset.Cash(1) | Asset.Bank(2) | Asset.Loan(3)\ + | Asset.Investment(4) | Asset.Other(5)\ + | Liability.CreditCard(-1) | Liability.Loan(-2) | Liability.Other(-3)" +} + +credit_card_info { + shape: sql_table + account_id: uuid {constraint: primary_key} + statement_days: "int: 1-31" + due_days: "int: 1-31"" +} +credit_card_info -> accounts: "\"account_id\" FK" + +loan_info { + shape: sql_table + account_id: uuid {constraint: primary_key} +} +loan_info -> accounts: "\"account_id\" FK" +# Accounts + + +# Sync +sync { + shape: sql_table + item_id: "uuid" {constraint: foreign_key} + state: "int: Syncing | Deleted | Synced" + last_updated: "long: epoch seconds" +} +sync -> transactions: "\"item_id\" FK" +sync -> due_transactions: "\"item_id\" FK" +sync -> accounts: "\"item_id\" FK" +sync -> categories: "\"item_id\" FK" +sync -> recurring_rules: "\"item_id\" FK" +# Sync + + +# Categories +categories { + shape: sql_table + id: uuid {constraint: primary_key} + name: string + icon_id: "string" + color_int: "int" + order_num: "double" + parent_category_id: "uuid (optional)" {constraint: foreign_key} +} +categories -> categories: "\"parent_category_id\" FK" +# Categories + + +# Tags +trn_tag_join { + shape: sql_table + transaction_id: uuid {constraint: primary_key} + tag_id: uuid {constraint: primary_key} +} +trn_tag_join -> transactions: "\"transction_id\" FK" +trn_tag_join -> tags: "\"tag_id\" FK" +tags { + shape: sql_table + id: uuid {constraint: primary_key} + name: string + color_int: "int" + order_num: "double" +} +# Tags + + +# Attachments +trn_attachement_join { + shape: sql_table + attachment_id: uuid {constraint: primary_key} + tag_id: uuid {constraint: primary_key} +} +trn_attachement_join -> transactions: "\"transction_id\" FK" +trn_attachement_join -> attachments: "\"attachment_id\" FK" +attachments { + shape: sql_table + id: uuid {constraint: primary_key} + transaction_id: uuid {constraint: foreign_key} + uri: "string" + name: "string" +} +# Attachments + + +# Recurring +recurring_tag_join { + shape: sql_table + recurring_rule_id: uuid {constraint: primary_key} + tag_id: uuid {constraint: primary_key} +} +recurring_tag_join -> recurring_rules: "\"recurring_rule_id\" FK" +recurring_tag_join -> tags: "\"tag_id\" FK" + +recurring_attachment_join { + shape: sql_table + recurring_rule_id: uuid {constraint: primary_key} + attachment_id: uuid {constraint: primary_key} +} +recurring_attachment_join -> recurring_rules: "\"recurring_rule_id\" FK" +recurring_attachment_join -> attachments: "\"attachment_id\" FK" + +recurring_rules { + shape: sql_table + id: uuid {constraint: primary_key} + start_time: "long: epoch seconds" + interval_seconds: "long" + end_time: "long: epoch seconds (optional)" + auto_execute: boolean + entries_json: "string: entries JSON" + category_id: "uuid (optional)" {constraint: foreign_key} + title: "string (optional)" + description: "string (optional)" +} +recurring_rules -> categories: "\"category_id\" FK" +# Recurring + + +# Ledger & transactions +ledger { + shape: sql_table + entry_id: "uuid" {constraint: primary_key} + from_account: "uuid (optional)" {constraint: foreign_key} + from_amount: "positive double (optional)" + from_asset_code: "string (optional)" + to_account: "uuid (optional)" {constraint: foreign_key} + to_amount: "positive double (optional)" + to_asset_code: "string (optional)" + timestamp: "long: epoch seconds" + special_purpose: "int: None(0) | Fee(1)" + transaction_id: "uuid" {constraint: foreign_key} +} +ledger -> accounts: "\"from_account\" FK" +ledger -> accounts: "\"to_account\" FK" +ledger -> transactions: "\"transaction_id\" FK" + +transactions { + shape: sql_table + id: uuid {constraint: primary_key} + timestamp: "long: epoch seconds" + title: "string (optional)" + description: "string (optional)" + category_id: "uuid (optional)" {constraint: foreign_key} + recurring_rule_id: "uuid (optional)" {constraint: foreign_key} + hidden: "boolean" +} +transactions -> categories: "\"category_id\" FK" +transactions -> recurring_rules: "\"recurring_rule_id\" FK" +# Ledger & transactions + + +# [DUE] Ledger & transactions +due_ledger { + shape: sql_table + entry_id: "uuid" {constraint: primary_key} + from_account: "uuid (optional)" {constraint: foreign_key} + from_amount: "positive double (optional)" + from_asset_code: "string (optional)" + to_account: "uuid (optional)" {constraint: foreign_key} + to_amount: "positive double (optional)" + to_asset_code: "string (optional)" + timestamp: "long: epoch seconds" + special_purpose: "int: None(0) | Fee(1)" + transaction_id: "uuid" {constraint: foreign_key} +} +due_ledger -> accounts: "\"from_account\" FK" +due_ledger -> accounts: "\"to_account\" FK" +due_ledger -> due_transactions: "\"transaction_id\" FK" + +due_transactions { + shape: sql_table + id: uuid {constraint: primary_key} + timestamp: "long: epoch seconds" + title: "string (optional)" + description: "string (optional)" + category_id: "uuid (optional)" {constraint: foreign_key} + recurring_rule_id: "uuid (optional)" {constraint: foreign_key} +} +due_transactions -> categories: "\"category_id\" FK" +due_transactions -> recurring_rules: "\"recurring_rule_id\" FK" +# [DUE] Ledger & transactions + + +# Account cache +account_cache { + shape: sql_table + account_id: uuid {constraint: primary_key} + incomes_json: "string: RawStats as JSON" + expenses_json: "string: RawStats as JSON" + incomes_count: "int" + expenses_count: "int" + timestamp: "long: epoch seconds" +} +account_cache -> accounts: "\"account_id\" FK" +# Account cache \ No newline at end of file diff --git a/assets/database_schema.svg b/assets/database_schema.svg new file mode 100644 index 0000000..ae2b319 --- /dev/null +++ b/assets/database_schema.svg @@ -0,0 +1,248 @@ + +accountsid +uuid +PKname +string +icon_id +string +color_int +int +asset_code +string +order_num +double +archived +boolean +include_in_balance +boolean +type +int: Asset.Cash(1) | Asset.Bank(2) | Asset.Loan(3) | Asset.Investment(4) | Asset.Other(5) | Liability.CreditCard(-1) | Liability.Loan(-2) | Liability.Other(-3) +syncitem_id +uuid +FKstate +int: Syncing | Deleted | Synced +last_updated +long: epoch seconds +categoriesid +uuid +PKname +string +icon_id +string +color_int +int +order_num +double +parent_category_id +uuid (optional) +FKtrn_tag_jointransaction_id +uuid +PKtag_id +uuid +PKtagsid +uuid +PKname +string +color_int +int +order_num +double +trn_attachement_joinattachment_id +uuid +PKtag_id +uuid +PKattachmentsid +uuid +PKtransaction_id +uuid +FKuri +string +name +string +recurring_tag_joinrecurring_rule_id +uuid +PKtag_id +uuid +PKrecurring_attachment_joinrecurring_rule_id +uuid +PKattachment_id +uuid +PKrecurring_rulesid +uuid +PKstart_time +long: epoch seconds +interval_seconds +long +end_time +long: epoch seconds (optional) +auto_execute +boolean +entries_json +string: entries JSON +category_id +uuid (optional) +FKtitle +string (optional) +description +string (optional) +ledgerentry_id +uuid +PKfrom_account +uuid (optional) +FKfrom_amount +positive double (optional) +from_asset_code +string (optional) +to_account +uuid (optional) +FKto_amount +positive double (optional) +to_asset_code +string (optional) +timestamp +long: epoch seconds +special_purpose +int: None(0) | Fee(1) +transaction_id +uuid +FKtransactionsid +uuid +PKtimestamp +long: epoch seconds +title +string (optional) +description +string (optional) +category_id +uuid (optional) +FKrecurring_rule_id +uuid (optional) +FKhidden +boolean +due_ledgerentry_id +uuid +PKfrom_account +uuid (optional) +FKfrom_amount +positive double (optional) +from_asset_code +string (optional) +to_account +uuid (optional) +FKto_amount +positive double (optional) +to_asset_code +string (optional) +timestamp +long: epoch seconds +special_purpose +int: None(0) | Fee(1) +transaction_id +uuid +FKdue_transactionsid +uuid +PKtimestamp +long: epoch seconds +title +string (optional) +description +string (optional) +category_id +uuid (optional) +FKrecurring_rule_id +uuid (optional) +FKaccount_cacheaccount_id +uuid +PKincomes_json +string: RawStats as JSON +expenses_json +string: RawStats as JSON +incomes_count +int +expenses_count +int +timestamp +long: epoch seconds + "item_id" FK"item_id" FK"item_id" FK"item_id" FK"item_id" FK"parent_category_id" FK"transction_id" FK"tag_id" FK"transction_id" FK"attachment_id" FK"recurring_rule_id" FK"tag_id" FK"recurring_rule_id" FK"attachment_id" FK"category_id" FK"from_account" FK"to_account" FK"transaction_id" FK"category_id" FK"recurring_rule_id" FK"from_account" FK"to_account" FK"transaction_id" FK"category_id" FK"recurring_rule_id" FK"account_id" FK + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/total_balance_algo.d2 b/assets/total_balance_algo.d2 new file mode 100644 index 0000000..ddee25b --- /dev/null +++ b/assets/total_balance_algo.d2 @@ -0,0 +1,8 @@ + +TotalBalanceFlow { + deps: Dependencies { + + } +} + +WIP! \ No newline at end of file diff --git a/assets/total_balance_algo.svg b/assets/total_balance_algo.svg new file mode 100644 index 0000000..079a3dc --- /dev/null +++ b/assets/total_balance_algo.svg @@ -0,0 +1,59 @@ + +TotalBalanceFlowWIP!Dependencies + + + diff --git a/assets/trn_history_algo.d2 b/assets/trn_history_algo.d2 new file mode 100644 index 0000000..ee288ef --- /dev/null +++ b/assets/trn_history_algo.d2 @@ -0,0 +1,114 @@ +db: Database { + shape: cylinder + transactions { + dao: CalcHistoryTrnDao { + "SELECT ... FROM transactions WHERE (time BETWEEN ? AND ?) AND ..." + } + + type: CalcHistoryTrn { + shape: class + id: String + amount: Double + currency: String + type: "Income | Expense" + time: Instant + timeType: "Actual | Due" + title: String? + description: String? + account_id: String + catgory_id: String? + purpose: "null | TransferFrom | TransferTo | Fee" + state: "Default | Hidden" + } + + dao -> type + } + + trn_links { + dao: TrnLinksDao { + "SELECT ... FROM trn_links WHERE trnId IN (?)" + } + + type: TrnLinkRecord { + shape: class + batchId: String + trnId: String + } + + dao -> type + } +} + +TrnHistoryFlow { + in: Input { + shape: class + query: "All | ByCategory(id: String) | ByAccount(id: String)" + } + + deps: Dependencies { + shape: class + calcHistoryTrnDao: CalHistoryTrnDao + trnLinksDao: TrnLinksDao + selectedPeriodFlow: SelectedPeriodFlow + baseCurrencyFlow: BaseCurrencyFlow + rateFlow: RateFlow + accountsFlow: AccountsFlow + categoriesFlow: CategoriesFlow + itemIconAct: ItemIconAct + } + + p: Process { + period: "SelectedPeriodFlow { period ->" + trns: "calcHistoryTrnDao.findAllInPeriod(period) { trns ->" + grp_batch: "Group by day and batch transfers" + calc_income_expense: "CalcAlgo(trns)" + + period -> trns + trns -> grp_batch + trns -> calc_income_expense + } + + in -> p: Query + deps -> p + + out: Output { + shape: class + income: ValueUi? + expense: ValueUi? + trns: List + } + + p -> out: Produces +} + +TrnListItemUi { + DueDividerUi { + shape: class + } + + DateDividerUi { + shape: class + } + + TransactionUi { + shape: class + id: String + amount: ValueUi + account: AccountUi + category: CategoryUi? + title: String? + description: String? + type: "Income | Expense" + dueDateFormatted: String? + } + + TransferCardUi { + shape: class + batchId: String + } +} + +TrnListItemUi -> TrnHistoryFlow.out + +db.transactions.type -> TrnHistoryFlow.deps.calcHistoryTrnDao +db.trn_links.type -> TrnHistoryFlow.deps.trnLinksDao \ No newline at end of file diff --git a/assets/trn_history_algo.svg b/assets/trn_history_algo.svg new file mode 100644 index 0000000..8cf582a --- /dev/null +++ b/assets/trn_history_algo.svg @@ -0,0 +1,718 @@ + + + + + + + + + Database + + + + + + + TrnHistoryFlow + + + + + + + TrnListItemUi + + + + + + + transactions + + + + + + + trn_links + + + + + + + Input + + + + + query + + All | ByCategory(id: String) | + ByAccount(id: String) + + + + + + + + + Dependencies + + + + + calcHistoryTrnDao + + CalHistoryTrnDao + + + + + trnLinksDao + + TrnLinksDao + + + + + selectedPeriodFlow + + SelectedPeriodFlow + + + + + baseCurrencyFlow + + BaseCurrencyFlow + + + + + rateFlow + + RateFlow + + + + + accountsFlow + + AccountsFlow + + + + + categoriesFlow + + CategoriesFlow + + + + + itemIconAct + + ItemIconAct + + + + + + + + + Process + + + + + + + Output + + + + + income + + ValueUi? + + + + + expense + + ValueUi? + + + + + trns + + List<TrnListItemUi> + + + + + + + + + DueDividerUi + + + + + + + + + DateDividerUi + + + + + + + + + TransactionUi + + + + + id + + String + + + + + amount + + ValueUi + + + + + account + + AccountUi + + + + + category + + CategoryUi? + + + + + title + + String? + + + + + description + + String? + + + + + type + + Income | Expense + + + + + dueDateFormatted + + String? + + + + + + + + + TransferCardUi + + + + + batchId + + String + + + + + + + + + CalcHistoryTrnDao + + + + + + + CalcHistoryTrn + + + + + id + + String + + + + + amount + + Double + + + + + currency + + String + + + + + type + + Income | Expense + + + + + time + + Instant + + + + + timeType + + Actual | Due + + + + + title + + String? + + + + + description + + String? + + + + + account_id + + String + + + + + catgory_id + + String? + + + + + purpose + + null | TransferFrom | TransferTo + | Fee + + + + + state + + Default | Hidden + + + + + + + + + TrnLinksDao + + + + + + + TrnLinkRecord + + + + + batchId + + String + + + + + trnId + + String + + + + + + + + + SelectedPeriodFlow { period -> + + + + + + + + calcHistoryTrnDao.findAllInPeriod(period) { trns -> + + + + + + + Group by day and batch transfers + + + + + + + CalcAlgo(trns) + + + + + + + SELECT ... FROM transactions + WHERE (time BETWEEN ? AND ?) AND ... + + + + + + + SELECT ... FROM trn_links WHERE + trnId IN (?) + + + + + + + + + + + + + + + + + + + + + + + Query + + + + + + + + Produces + + + + + + + + + + + + + + + + + diff --git a/backup/README.md b/backup/README.md new file mode 100644 index 0000000..45301b2 --- /dev/null +++ b/backup/README.md @@ -0,0 +1,12 @@ +# Backup + +**Purpose:** +- Import data from the old app +- Import data from the new app +- Export data the new app's data + +**Structure:** +- `backup:base`: shared module between for the inner implementations +- `backup:old`: import data from the old app +- `backup:impl`: export & import data from the new app ("new" can't be used as a package name) +- `backup:api`: public API for the backup module (other modules will add dependency only to this module) \ No newline at end of file diff --git a/backup/api/.gitignore b/backup/api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/backup/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/backup/api/README.md b/backup/api/README.md new file mode 100644 index 0000000..cc7d185 --- /dev/null +++ b/backup/api/README.md @@ -0,0 +1,3 @@ +# Backup API + +Exposes the backup functionality to other module. Consists mainly of interfaces. \ No newline at end of file diff --git a/backup/api/build.gradle.kts b/backup/api/build.gradle.kts new file mode 100644 index 0000000..a7493fe --- /dev/null +++ b/backup/api/build.gradle.kts @@ -0,0 +1,22 @@ +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":navigation")) + implementation(project(":core:ui")) + implementation(project(":core:domain")) + implementation(project(":design-system")) + implementation(project(":backup:base")) + implementation(project(":backup:old")) + implementation(project(":backup:impl")) + Testing() +} \ No newline at end of file diff --git a/backup/api/src/main/AndroidManifest.xml b/backup/api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7d8813c --- /dev/null +++ b/backup/api/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/backup/api/src/main/java/com/ivy/api/ImportBackupDataAct.kt b/backup/api/src/main/java/com/ivy/api/ImportBackupDataAct.kt new file mode 100644 index 0000000..23586fc --- /dev/null +++ b/backup/api/src/main/java/com/ivy/api/ImportBackupDataAct.kt @@ -0,0 +1,70 @@ +package com.ivy.api + +import android.content.Context +import android.net.Uri +import arrow.core.Either +import arrow.core.computations.either +import com.ivy.backup.base.ImportBackupError +import com.ivy.backup.base.OnImportProgress +import com.ivy.backup.base.WriteBackupDataAct +import com.ivy.backup.base.data.BackupData +import com.ivy.backup.base.data.ImportResult +import com.ivy.backup.base.extractBackupJson +import com.ivy.core.domain.action.Action +import com.ivy.impl.load.ParseV1JsonDataAct +import com.ivy.old.parse.ParseOldJsonAct +import dagger.hilt.android.qualifiers.ApplicationContext +import org.json.JSONObject +import javax.inject.Inject + +class ImportBackupDataAct @Inject constructor( + @ApplicationContext + private val context: Context, + private val parseOldJsonAct: ParseOldJsonAct, + private val parseV1JsonDataAct: ParseV1JsonDataAct, + private val writeBackupDataAct: WriteBackupDataAct, +) : Action>() { + data class Input( + val backupZipPath: Uri, + val onProgress: OnImportProgress, + ) + + override suspend fun action(input: Input): Either = + either { + val progress = { percent: Float, message: String -> + input.onProgress.onProgress(percent, message) + } + + progress(1f, "Extracting backup JSON...") + val backupJson = extractBackupJson(context, input.backupZipPath).bind() + progress(3f, "Parsing backup JSON...") + val parser = determineParser(backupJson) + val backupData = parser(backupJson).bind() + + progress(10f, "Backup JSON parsed. Saving to database...") + writeBackupDataAct( + WriteBackupDataAct.Input( + backup = backupData, + onProgress = object : OnImportProgress { + override fun onProgress(percent: Float, message: String) { + // Adjust from 13% to 100% + val adjustedPercent = 0.13f + (0.87f * percent) + progress(adjustedPercent, message) + } + } + ) + ) + + ImportResult( + faultyTransfers = backupData.transfers.faulty + ) + } + + private suspend fun determineParser( + backupJson: JSONObject + ): suspend (JSONObject) -> Either = when { + backupJson.has("backupInfo") -> { json: JSONObject -> parseV1JsonDataAct(json) } + else -> { json: JSONObject -> parseOldJsonAct(json) } + } +} + diff --git a/backup/api/src/main/java/com/ivy/api/screen/backup/ImportBackupEvent.kt b/backup/api/src/main/java/com/ivy/api/screen/backup/ImportBackupEvent.kt new file mode 100644 index 0000000..e8e810b --- /dev/null +++ b/backup/api/src/main/java/com/ivy/api/screen/backup/ImportBackupEvent.kt @@ -0,0 +1,8 @@ +package com.ivy.api.screen.backup + +import android.net.Uri + +sealed interface ImportBackupEvent { + data class ImportFile(val fileUri: Uri) : ImportBackupEvent + object Finish : ImportBackupEvent +} \ No newline at end of file diff --git a/backup/api/src/main/java/com/ivy/api/screen/backup/ImportBackupScreen.kt b/backup/api/src/main/java/com/ivy/api/screen/backup/ImportBackupScreen.kt new file mode 100644 index 0000000..974e55c --- /dev/null +++ b/backup/api/src/main/java/com/ivy/api/screen/backup/ImportBackupScreen.kt @@ -0,0 +1,163 @@ +package com.ivy.api.screen.backup + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ivy.core.ui.rootScreen +import com.ivy.data.file.FileType +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.* +import com.ivy.design.l3_ivyComponents.BackButton +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview + +@Composable +fun BoxScope.ImportBackupScreen() { + val viewModel: ImportBackupViewModel = hiltViewModel() + val state by viewModel.uiState.collectAsState() + UI(state = state, onEvent = viewModel::onEvent) +} + +@Composable +private fun UI( + state: ImportBackupState, + onEvent: (ImportBackupEvent) -> Unit +) { + ColumnRoot( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + SpacerVer(height = 16.dp) + val notInProgress = state.progress == null || state.result != null + Row( + verticalAlignment = Alignment.CenterVertically + ) { + if (notInProgress) { + BackButton { + onEvent(ImportBackupEvent.Finish) + } + SpacerHor(width = 16.dp) + B1(text = "Import Backup") + } + } + BackHandler(enabled = notInProgress) { + onEvent(ImportBackupEvent.Finish) + } + + SpacerWeight(weight = 1f) + + if (state.progress != null) { + Progress(progress = state.progress) + } + + if (state.result != null) { + SpacerVer(height = 32.dp) + Result(result = state.result) + } + + if (notInProgress && state.result !is ImportResult.Success) { + SpacerVer(height = 24.dp) + val rootScreen = rootScreen() + IvyButton( + size = ButtonSize.Big, + visibility = Visibility.Focused, + feeling = Feeling.Positive, + text = "Import Backup .zip" + ) { + rootScreen.fileChooser(fileType = FileType.Zip) { + onEvent(ImportBackupEvent.ImportFile(it)) + } + } + } + + if (state.result is ImportResult.Success) { + SpacerVer(height = 12.dp) + IvyButton( + size = ButtonSize.Big, + visibility = Visibility.Focused, + feeling = Feeling.Positive, + text = "Finish" + ) { + onEvent(ImportBackupEvent.Finish) + } + } + SpacerWeight(weight = 1f) + } +} + +// region Progress +@Composable +private fun ColumnScope.Progress( + progress: Progress +) { + ProgressBar(percent = progress.percent) + SpacerVer(height = 12.dp) + B2(text = progress.message, color = UI.colors.orange) +} + +@Composable +private fun ProgressBar( + percent: Float, + modifier: Modifier = Modifier +) { + Box(modifier) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .background(UI.colors.medium, UI.shapes.rounded) + .border(1.dp, UI.colors.primary, UI.shapes.rounded) + ) + Spacer( + modifier = Modifier + .fillMaxWidth(fraction = percent) + .height(48.dp) + .background(UI.colors.primary, UI.shapes.rounded) + ) + } +} +// endregion + +@Composable +private fun ColumnScope.Result( + result: ImportResult +) { + H2( + text = result.message, + color = when (result) { + is ImportResult.Error -> UI.colors.red + is ImportResult.Success -> UI.colors.green + } + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + UI( + state = ImportBackupState( + progress = Progress( + percent = 0.65f, + message = "Progress message..." + ), + result = ImportResult.Success("Success message") + ), + onEvent = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/backup/api/src/main/java/com/ivy/api/screen/backup/ImportBackupState.kt b/backup/api/src/main/java/com/ivy/api/screen/backup/ImportBackupState.kt new file mode 100644 index 0000000..d536c90 --- /dev/null +++ b/backup/api/src/main/java/com/ivy/api/screen/backup/ImportBackupState.kt @@ -0,0 +1,26 @@ +package com.ivy.api.screen.backup + +import androidx.compose.runtime.Immutable + +@Immutable +data class ImportBackupState( + val progress: Progress?, + val result: ImportResult?, +) + +@Immutable +data class Progress( + val percent: Float, + val message: String +) + +@Immutable +sealed interface ImportResult { + val message: String + + @Immutable + data class Error(override val message: String) : ImportResult + + @Immutable + data class Success(override val message: String) : ImportResult +} \ No newline at end of file diff --git a/backup/api/src/main/java/com/ivy/api/screen/backup/ImportBackupViewModel.kt b/backup/api/src/main/java/com/ivy/api/screen/backup/ImportBackupViewModel.kt new file mode 100644 index 0000000..7301562 --- /dev/null +++ b/backup/api/src/main/java/com/ivy/api/screen/backup/ImportBackupViewModel.kt @@ -0,0 +1,80 @@ +package com.ivy.api.screen.backup + +import androidx.lifecycle.viewModelScope +import arrow.core.Either +import com.ivy.backup.base.OnImportProgress +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.navigation.Navigator +import com.ivy.old.ImportOldJsonBackupAct +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ImportBackupViewModel @Inject constructor( + private val navigator: Navigator, + private val importOldJsonBackupAct: ImportOldJsonBackupAct, +) : SimpleFlowViewModel() { + override val initialUi = ImportBackupState( + progress = null, + result = null, + ) + + private val progress = MutableStateFlow(initialUi.progress) + private val result = MutableStateFlow(initialUi.result) + + override val uiFlow: Flow = combine( + progress, result + ) { progress, result -> + ImportBackupState( + progress = progress, + result = result, + ) + } + + + // region Event Handling + override suspend fun handleEvent(event: ImportBackupEvent): Unit = when (event) { + ImportBackupEvent.Finish -> handleFinish() + is ImportBackupEvent.ImportFile -> handleImportFile(event) + } + + private fun handleFinish() { + if (progress.value == null || result.value != null) { + // nothing in progress or import finished + navigator.back() + } + } + + private fun handleImportFile(event: ImportBackupEvent.ImportFile) { + // Launch new scope so we won't block the event queue + viewModelScope.launch { + if (progress.value != null && result.value == null) return@launch + + result.value = null + val res = importOldJsonBackupAct( + ImportOldJsonBackupAct.Input( + backupZipPath = event.fileUri, + onProgress = object : OnImportProgress { + override fun onProgress(percent: Float, message: String) { + progress.value = Progress(percent, message) + } + } + ) + ) + + result.value = when (res) { + is Either.Left -> ImportResult.Error( + res.value.reason?.message ?: res.value.toString() + ) + is Either.Right -> ImportResult.Success( + message = "Success. Found faulty transfers: ${res.value.faultyTransfers}" + ) + } + } + } + // endregion +} \ No newline at end of file diff --git a/backup/base/.gitignore b/backup/base/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/backup/base/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/backup/base/README.md b/backup/base/README.md new file mode 100644 index 0000000..ed29864 --- /dev/null +++ b/backup/base/README.md @@ -0,0 +1,3 @@ +# Backup Base + +Shared dependencies between the backup modules that implement the backup functionality. \ No newline at end of file diff --git a/backup/base/build.gradle.kts b/backup/base/build.gradle.kts new file mode 100644 index 0000000..dc4bc26 --- /dev/null +++ b/backup/base/build.gradle.kts @@ -0,0 +1,17 @@ +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":core:domain")) + implementation(project(":android:file-system")) + Testing() +} \ No newline at end of file diff --git a/backup/base/src/main/AndroidManifest.xml b/backup/base/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2e4ba90 --- /dev/null +++ b/backup/base/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/backup/base/src/main/java/com/ivy/backup/base/ExtractBackupJson.kt b/backup/base/src/main/java/com/ivy/backup/base/ExtractBackupJson.kt new file mode 100644 index 0000000..ae2db63 --- /dev/null +++ b/backup/base/src/main/java/com/ivy/backup/base/ExtractBackupJson.kt @@ -0,0 +1,84 @@ +package com.ivy.backup.base + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import arrow.core.Either +import arrow.core.NonEmptyList +import arrow.core.computations.either +import arrow.core.left +import arrow.core.right +import com.ivy.common.toNonEmptyList +import com.ivy.file.readFile +import com.ivy.file.unzip +import org.json.JSONObject +import java.io.File + +suspend fun extractBackupJson( + context: Context, + backupFilePath: Uri +): Either = + either { + // region Unzip + val files = unzipBackupZip(context, zipFilePath = backupFilePath).bind() + val backupJsonString = readBackupJson(context, files).bind() + // endregion + + // region Parse + parse(backupJsonString).bind() + } + + +// region Unzip +private fun unzipBackupZip( + context: Context, + zipFilePath: Uri +): Either> { + val folderName = "backup" + System.currentTimeMillis() + val unzippedFolder = File(context.cacheDir, folderName) + + unzip( + context = context, + zipFilePath = zipFilePath, + unzipLocation = unzippedFolder + ) + + val unzippedFiles = unzippedFolder.listFiles()?.toList() + ?.takeIf { it.isNotEmpty() } + ?.toNonEmptyList() + ?: return ImportBackupError.UnzipFailed(null).left() + + unzippedFolder.delete() + + return unzippedFiles.right() +} + +private fun readBackupJson( + context: Context, + files: NonEmptyList +): Either { + fun hasJsonExtension(file: File): Boolean { + val name = file.name + val lastIndexOf = name.lastIndexOf(".") + .takeIf { it != -1 } ?: return false + return (name.substring(lastIndexOf).equals(".json", true)) + } + + val jsonFiles = files.filter(::hasJsonExtension) + if (jsonFiles.size != 1) + return ImportBackupError.UnexpectedBackupZipFormat(null).left() + + return readFile( + context, + jsonFiles.first().toUri(), + Charsets.UTF_16 + )?.right() ?: ImportBackupError.FailedToReadJsonFile(null).left() +} +// endregion + +// region Parse +private fun parse(jsonString: String): Either = + Either.catch({ ImportBackupError.FailedToParseJson(it) }) { + JSONObject(jsonString) + } +// endregion \ No newline at end of file diff --git a/backup/base/src/main/java/com/ivy/backup/base/ImportBackupError.kt b/backup/base/src/main/java/com/ivy/backup/base/ImportBackupError.kt new file mode 100644 index 0000000..d12ad2d --- /dev/null +++ b/backup/base/src/main/java/com/ivy/backup/base/ImportBackupError.kt @@ -0,0 +1,20 @@ +package com.ivy.backup.base + +sealed interface ImportBackupError { + val reason: Throwable? + + data class UnzipFailed(override val reason: Throwable?) : ImportBackupError + data class UnexpectedBackupZipFormat(override val reason: Throwable?) : ImportBackupError + data class FailedToReadJsonFile(override val reason: Throwable?) : ImportBackupError + data class FailedToParseJson(override val reason: Throwable) : ImportBackupError + + sealed interface Parse : ImportBackupError { + data class Accounts(override val reason: Throwable) : Parse + data class AccountFolders(override val reason: Throwable) : Parse + data class Attachments(override val reason: Throwable) : Parse + data class Categories(override val reason: Throwable) : Parse + data class Transactions(override val reason: Throwable) : Parse + data class Transfers(override val reason: Throwable) : Parse + data class Settings(override val reason: Throwable) : Parse + } +} \ No newline at end of file diff --git a/backup/base/src/main/java/com/ivy/backup/base/ImportProgress.kt b/backup/base/src/main/java/com/ivy/backup/base/ImportProgress.kt new file mode 100644 index 0000000..52ec139 --- /dev/null +++ b/backup/base/src/main/java/com/ivy/backup/base/ImportProgress.kt @@ -0,0 +1,5 @@ +package com.ivy.backup.base + +interface OnImportProgress { + fun onProgress(percent: Float, message: String) +} \ No newline at end of file diff --git a/backup/base/src/main/java/com/ivy/backup/base/ParseBackupUtil.kt b/backup/base/src/main/java/com/ivy/backup/base/ParseBackupUtil.kt new file mode 100644 index 0000000..caeeab9 --- /dev/null +++ b/backup/base/src/main/java/com/ivy/backup/base/ParseBackupUtil.kt @@ -0,0 +1,61 @@ +package com.ivy.backup.base + +import arrow.core.Either +import com.ivy.common.time.beginningOfIvyTime +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.toLocal +import com.ivy.common.toUUID +import com.ivy.data.transaction.TrnTime +import org.json.JSONObject +import java.time.Instant +import java.time.LocalDateTime +import java.util.* + + +fun maybe(block: () -> T): T? = try { + block() +} catch (e: Exception) { + null +} + +fun parseItems( + json: JSONObject, + key: String, + error: (Throwable) -> E, + parse: JSONObject.() -> T +): Either> = + Either.catch(error) { + val itemsJson = json.getJSONArray(key) + val items = mutableListOf() + for (i in 0 until itemsJson.length()) { + val itemJson = itemsJson.getJSONObject(i) + items.add(itemJson.parse()) + } + items + } + + +fun parseTrnTime( + trnJson: JSONObject, + timeProvider: TimeProvider, +): TrnTime { + return trnJson.parseDateTime("dateTime", timeProvider) + ?.let(TrnTime::Actual) ?: trnJson.parseDateTime("dueDate", timeProvider) + ?.let(TrnTime::Due) ?: TrnTime.Actual( + beginningOfIvyTime() + ) +} + +fun JSONObject.parseDateTime( + field: String, + timeProvider: TimeProvider +): LocalDateTime? = + maybe { getLong(field) } + ?.let { epochMillis -> + Instant.ofEpochMilli(epochMillis) + .toLocal(timeProvider) + } + + +fun JSONObject.optionalUUID(field: String): UUID? = + maybe { getString(field).toUUID() } \ No newline at end of file diff --git a/backup/base/src/main/java/com/ivy/backup/base/WriteBackupDataAct.kt b/backup/base/src/main/java/com/ivy/backup/base/WriteBackupDataAct.kt new file mode 100644 index 0000000..d965816 --- /dev/null +++ b/backup/base/src/main/java/com/ivy/backup/base/WriteBackupDataAct.kt @@ -0,0 +1,139 @@ +package com.ivy.backup.base + +import com.ivy.backup.base.data.BackupData +import com.ivy.backup.base.data.BatchTransferData +import com.ivy.core.domain.action.Action +import com.ivy.core.domain.action.account.WriteAccountsAct +import com.ivy.core.domain.action.category.WriteCategoriesAct +import com.ivy.core.domain.action.data.Modify +import com.ivy.core.domain.action.settings.basecurrency.WriteBaseCurrencyAct +import com.ivy.core.domain.action.settings.theme.WriteThemeAct +import com.ivy.core.domain.action.transaction.TrnsSignal +import com.ivy.core.domain.action.transaction.WriteTrnsAct +import com.ivy.core.domain.action.transaction.transfer.ModifyTransfer +import com.ivy.core.domain.action.transaction.transfer.TransferByBatchIdAct +import com.ivy.core.domain.action.transaction.transfer.WriteTransferAct +import com.ivy.data.transaction.Transaction +import javax.inject.Inject + +class WriteBackupDataAct @Inject constructor( + private val writeAccountsAct: WriteAccountsAct, + private val writeCategoriesAct: WriteCategoriesAct, + private val writeTrnsAct: WriteTrnsAct, + private val writeTransferAct: WriteTransferAct, + private val writeBaseCurrencyAct: WriteBaseCurrencyAct, + private val writeThemeAct: WriteThemeAct, + private val transferByBatchIdAct: TransferByBatchIdAct, + private val trnsSignal: TrnsSignal, +) : Action() { + data class Input( + val backup: BackupData, + val onProgress: OnImportProgress?, + ) + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(input: Input) { + val backup = input.backup + val progress = { progress: Float, message: String -> + input.onProgress?.onProgress(progress, message) + } + + progress(0f, "Savings to database started...") + // region restore Settings + writeBaseCurrencyAct(backup.settings.baseCurrency) + writeThemeAct(backup.settings.theme) + // endregion + progress(0.05f, "Settings and theme imported.") + + writeAccountsAct(Modify.saveMany(backup.accounts)) + progress(0.1f, "[ACCOUNTS] ${backup.accounts.size} accounts imported.") + writeCategoriesAct(Modify.saveMany(backup.categories)) + progress(0.15f, "[CATEGORIES] ${backup.categories.size} categories imported.") + + progress(0.2f, "[TRANSACTIONS] Importing transactions...") + // TODO: Remove trnsSingal later cuz TrnsFlow is deprecated + trnsSignal.disable() // prevent spam + writeTrnsPaginated( + trns = backup.transactions, + pageSize = 100, + importedTrns = 0, + totalTrns = backup.transactions.size, + progress = { percent, message -> + // Transactions take from 20% to 75% + val adjustedPercent = 0.2f + (0.55f * percent) + progress(adjustedPercent, message) + }, + ) + + progress(0.76f, "[TRANSFERS] Importing transfers...") + restoreTransfers( + transfersData = backup.transfers.items, + progress = { percent, message -> + val adjustedPercent = 0.76f + (0.24f * percent) + progress(adjustedPercent, message) + } + ) + trnsSignal.enable() + progress(1f, "[WRITE SUCCESSFUL] Import completed!") + } + + private tailrec suspend fun writeTrnsPaginated( + trns: List, + pageSize: Int, + importedTrns: Int, + totalTrns: Int, + progress: (Float, String) -> Unit, + ) { + if (trns.isNotEmpty()) { + writeTrnsAct( + WriteTrnsAct.Input.ManyInefficient( + trns.take(pageSize).map { + WriteTrnsAct.Input.SaveInefficient(it) + } + ) + ) + + progress( + importedTrns.toFloat() / totalTrns, + "[TRANSACTIONS] Imported $importedTrns/$totalTrns transactions." + ) + writeTrnsPaginated( + trns = trns.drop(pageSize), + pageSize = pageSize, + importedTrns = importedTrns + pageSize, + totalTrns = totalTrns, + progress = progress, + ) + } + } + + private suspend fun restoreTransfers( + transfersData: List, + progress: (Float, String) -> Unit + ) { + val total = transfersData.size + for ((index, data) in transfersData.withIndex()) { + if (transferByBatchIdAct(data.batchId) != null) { + // transfer already exists, update it + writeTransferAct( + ModifyTransfer.edit( + batchId = data.batchId, + data = data.transfer + ) + ) + } else { + // it's a new transfer, add it + writeTransferAct( + ModifyTransfer.add( + batchId = data.batchId, + data = data.transfer + ) + ) + } + progress( + (index + 1) / total.toFloat(), + "[TRANSFERS] Imported ${index + 1}/$total transfers" + ) + } + } +} \ No newline at end of file diff --git a/backup/base/src/main/java/com/ivy/backup/base/WriteIvyWalletDataAct.kt b/backup/base/src/main/java/com/ivy/backup/base/WriteIvyWalletDataAct.kt new file mode 100644 index 0000000..475acb8 --- /dev/null +++ b/backup/base/src/main/java/com/ivy/backup/base/WriteIvyWalletDataAct.kt @@ -0,0 +1,15 @@ +package com.ivy.backup.base + +import arrow.core.Either +import com.ivy.core.data.sync.IvyWalletData +import com.ivy.core.domain.action.Action +import com.ivy.core.domain.api.data.ActionError +import javax.inject.Inject + +class WriteIvyWalletDataAct @Inject constructor( + +) : Action>() { + override suspend fun action(input: IvyWalletData): Either { + TODO("Not yet implemented") + } +} \ No newline at end of file diff --git a/backup/base/src/main/java/com/ivy/backup/base/data/BackupData.kt b/backup/base/src/main/java/com/ivy/backup/base/data/BackupData.kt new file mode 100644 index 0000000..480bfdb --- /dev/null +++ b/backup/base/src/main/java/com/ivy/backup/base/data/BackupData.kt @@ -0,0 +1,27 @@ +package com.ivy.backup.base.data + +import com.ivy.data.account.Account +import com.ivy.data.account.AccountFolder +import com.ivy.data.attachment.Attachment +import com.ivy.data.category.Category +import com.ivy.data.tag.Tag +import com.ivy.data.transaction.Transaction +import java.util.* + +@Deprecated("will be removed!") +data class BackupData( + // region Core data + val accounts: List, + val categories: List, + val transactions: List, + val transfers: FaultTolerantList, + // endregion + + // region Ivy New data + val accountFolders: Map>?, + val tags: List?, + val attachments: List?, + // endregion + + val settings: SettingsData, +) \ No newline at end of file diff --git a/backup/base/src/main/java/com/ivy/backup/base/data/BatchTransferData.kt b/backup/base/src/main/java/com/ivy/backup/base/data/BatchTransferData.kt new file mode 100644 index 0000000..65c08ef --- /dev/null +++ b/backup/base/src/main/java/com/ivy/backup/base/data/BatchTransferData.kt @@ -0,0 +1,9 @@ +package com.ivy.backup.base.data + +import com.ivy.core.domain.action.transaction.transfer.TransferData + +@Deprecated("will be removed!") +data class BatchTransferData( + val batchId: String, + val transfer: TransferData +) \ No newline at end of file diff --git a/backup/base/src/main/java/com/ivy/backup/base/data/FaultTolerantList.kt b/backup/base/src/main/java/com/ivy/backup/base/data/FaultTolerantList.kt new file mode 100644 index 0000000..2a3cafa --- /dev/null +++ b/backup/base/src/main/java/com/ivy/backup/base/data/FaultTolerantList.kt @@ -0,0 +1,6 @@ +package com.ivy.backup.base.data + +data class FaultTolerantList( + val items: List, + val faulty: Int, +) \ No newline at end of file diff --git a/backup/base/src/main/java/com/ivy/backup/base/data/ImportResult.kt b/backup/base/src/main/java/com/ivy/backup/base/data/ImportResult.kt new file mode 100644 index 0000000..687048b --- /dev/null +++ b/backup/base/src/main/java/com/ivy/backup/base/data/ImportResult.kt @@ -0,0 +1,5 @@ +package com.ivy.backup.base.data + +data class ImportResult( + val faultyTransfers: Int, +) \ No newline at end of file diff --git a/backup/base/src/main/java/com/ivy/backup/base/data/SettingsData.kt b/backup/base/src/main/java/com/ivy/backup/base/data/SettingsData.kt new file mode 100644 index 0000000..f094b00 --- /dev/null +++ b/backup/base/src/main/java/com/ivy/backup/base/data/SettingsData.kt @@ -0,0 +1,9 @@ +package com.ivy.backup.base.data + +import com.ivy.data.Theme + +@Deprecated("will be removed!") +data class SettingsData( + val baseCurrency: String, + val theme: Theme, +) \ No newline at end of file diff --git a/backup/impl/.gitignore b/backup/impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/backup/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/backup/impl/README.md b/backup/impl/README.md new file mode 100644 index 0000000..3bf6977 --- /dev/null +++ b/backup/impl/README.md @@ -0,0 +1,5 @@ +# Backup Implementation + +**Implements:** +- `Import` backup JSON .ipzip for the new Ivy Wallet app's data. +- `Export` backup JSON .zip for the new Ivy Wallet app's data. \ No newline at end of file diff --git a/backup/impl/build.gradle.kts b/backup/impl/build.gradle.kts new file mode 100644 index 0000000..2baaf40 --- /dev/null +++ b/backup/impl/build.gradle.kts @@ -0,0 +1,21 @@ +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":core:domain")) + implementation(project(":core:data-model")) + implementation(project(":core:persistence")) + api(project(":backup:base")) + implementation(project(":android:file-system")) + implementation(project(":drive:google-drive")) + Testing() +} \ No newline at end of file diff --git a/backup/impl/src/main/AndroidManifest.xml b/backup/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d77d338 --- /dev/null +++ b/backup/impl/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/backup/impl/src/main/java/com/ivy/impl/export/BackupDataAct.kt b/backup/impl/src/main/java/com/ivy/impl/export/BackupDataAct.kt new file mode 100644 index 0000000..c5c1cf1 --- /dev/null +++ b/backup/impl/src/main/java/com/ivy/impl/export/BackupDataAct.kt @@ -0,0 +1,80 @@ +package com.ivy.impl.export + +import android.content.Context +import android.net.Uri +import arrow.core.Either +import arrow.core.computations.either +import com.ivy.core.domain.action.Action +import com.ivy.drive.google_drive.api.GoogleDriveService +import com.ivy.drive.google_drive.data.DriveMimeType +import com.ivy.file.readFileAsBytes +import com.ivy.file.zip +import com.ivy.impl.export.json.ExportBackupJsonAct +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject +import kotlin.io.path.Path + +class BackupDataAct @Inject constructor( + @ApplicationContext + private val appContext: Context, + private val exportBackupJsonAct: ExportBackupJsonAct, + private val googleDrive: GoogleDriveService, +) : Action>() { + companion object { + const val DRIVE_BACKUP_PATH = "Ivy-Wallet-DIR/backup/ivy-wallet-backup.zip" + } + + data class Input( + val backupFileLocation: Uri + ) + + override suspend fun action(input: Input): Either = + either { + val jsonBackup = exportBackupJsonAct(Unit).toString() + val backupZipBytes = saveBackupZipFile(jsonBackup, input.backupFileLocation).bind() + val uploadedToDrive = uploadToDriveIfConnected(backupZipBytes).bind() + BackupDataResult( + uploadedToDrive = uploadedToDrive + ) + } + + private fun saveBackupZipFile( + jsonBackup: String, + backupFileLocation: Uri, + ): Either = Either.catch( + BackupDataError::SaveBackupZipLocally + ) { + val backupFile = createJsonDataFile(appContext, jsonBackup) + zip(appContext, backupFileLocation, listOf(backupFile)) + // TODO: Maybe clear cache dir? + readFileAsBytes(appContext, backupFileLocation) ?: error("Couldn't read backup zip") + } + + private fun createJsonDataFile(context: Context, jsonString: String): File { + val fileNamePrefix = "backup_data" + val fileNameSuffix = ".json" + val outputDir = context.cacheDir + + val file = File.createTempFile(fileNamePrefix, fileNameSuffix, outputDir) + file.writeText(jsonString, Charsets.UTF_16) + return file + } + + private suspend fun uploadToDriveIfConnected( + backupZipBytes: ByteArray + ): Either = Either.catch( + BackupDataError::UploadToDrive + ) { + if (googleDrive.isMounted()) { + googleDrive.write( + path = Path(DRIVE_BACKUP_PATH), + content = backupZipBytes, + mimeType = DriveMimeType.ZIP + ) + true + } else false + } + +} + diff --git a/backup/impl/src/main/java/com/ivy/impl/export/BackupDataError.kt b/backup/impl/src/main/java/com/ivy/impl/export/BackupDataError.kt new file mode 100644 index 0000000..238af80 --- /dev/null +++ b/backup/impl/src/main/java/com/ivy/impl/export/BackupDataError.kt @@ -0,0 +1,8 @@ +package com.ivy.impl.export + +sealed interface BackupDataError { + val reason: Throwable? + + data class SaveBackupZipLocally(override val reason: Throwable?) : BackupDataError + data class UploadToDrive(override val reason: Throwable?) : BackupDataError +} \ No newline at end of file diff --git a/backup/impl/src/main/java/com/ivy/impl/export/BackupDataResult.kt b/backup/impl/src/main/java/com/ivy/impl/export/BackupDataResult.kt new file mode 100644 index 0000000..1f01721 --- /dev/null +++ b/backup/impl/src/main/java/com/ivy/impl/export/BackupDataResult.kt @@ -0,0 +1,5 @@ +package com.ivy.impl.export + +data class BackupDataResult( + val uploadedToDrive: Boolean +) \ No newline at end of file diff --git a/backup/impl/src/main/java/com/ivy/impl/export/json/ExportAccountsData.kt b/backup/impl/src/main/java/com/ivy/impl/export/json/ExportAccountsData.kt new file mode 100644 index 0000000..77d2bac --- /dev/null +++ b/backup/impl/src/main/java/com/ivy/impl/export/json/ExportAccountsData.kt @@ -0,0 +1,37 @@ +package com.ivy.impl.export.json + +import com.ivy.core.persistence.dao.account.AccountDao +import com.ivy.core.persistence.dao.account.AccountFolderDao +import org.json.JSONArray + +internal suspend fun exportAccountsJson( + accountDao: AccountDao +): JSONArray = exportJson( + findAll = accountDao::findAllBlocking, + json = { + put("id", it.id) + put("name", it.name) + put("currency", it.currency) + put("color", it.color) + put("icon", it.icon) + put("folderId", it.folderId) + put("orderNum", it.orderNum) + put("excluded", it.excluded) + put("state", it.state.code) + putSync(it.sync, it.lastUpdated) + }, +) + +internal suspend fun exportAccountFoldersJson( + accountFolderDao: AccountFolderDao +): JSONArray = exportJson( + findAll = accountFolderDao::findAllBlocking, + json = { + put("id", it.id) + put("name", it.name) + put("color", it.color) + put("icon", it.icon) + put("orderNum", it.orderNum) + putSync(it.sync, it.lastUpdated) + } +) \ No newline at end of file diff --git a/backup/impl/src/main/java/com/ivy/impl/export/json/ExportAttachments.kt b/backup/impl/src/main/java/com/ivy/impl/export/json/ExportAttachments.kt new file mode 100644 index 0000000..6b8f0ca --- /dev/null +++ b/backup/impl/src/main/java/com/ivy/impl/export/json/ExportAttachments.kt @@ -0,0 +1,19 @@ +package com.ivy.impl.export.json + +import com.ivy.core.persistence.dao.AttachmentDao +import org.json.JSONArray + +internal suspend fun exportAttachmentsJson( + attachmentDao: AttachmentDao +): JSONArray = exportJson( + findAll = attachmentDao::findAllBlocking, + json = { + put("id", it.id) + put("associatedId", it.associatedId) + put("uri", it.uri) + put("source", it.source.code) + put("filename", it.filename) + put("type", it.type?.code) + putSync(it.sync, it.lastUpdated) + } +) \ No newline at end of file diff --git a/backup/impl/src/main/java/com/ivy/impl/export/json/ExportBackupJsonAct.kt b/backup/impl/src/main/java/com/ivy/impl/export/json/ExportBackupJsonAct.kt new file mode 100644 index 0000000..a72ac26 --- /dev/null +++ b/backup/impl/src/main/java/com/ivy/impl/export/json/ExportBackupJsonAct.kt @@ -0,0 +1,84 @@ +package com.ivy.impl.export.json + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.toUtc +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.dao.AttachmentDao +import com.ivy.core.persistence.dao.account.AccountDao +import com.ivy.core.persistence.dao.account.AccountFolderDao +import com.ivy.core.persistence.dao.category.CategoryDao +import com.ivy.core.persistence.dao.exchange.ExchangeRateOverrideDao +import com.ivy.core.persistence.dao.tag.TagDao +import com.ivy.core.persistence.dao.trn.TransactionDao +import com.ivy.core.persistence.dao.trn.TrnLinkRecordDao +import com.ivy.core.persistence.dao.trn.TrnMetadataDao +import com.ivy.core.persistence.dao.trn.TrnTagDao +import com.ivy.core.persistence.datastore.IvyDataStore +import com.ivy.core.persistence.datastore.keys.SettingsKeys +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import org.json.JSONObject +import javax.inject.Inject + +/** + * Exports all Ivy Wallet's data as a JSON. + */ +class ExportBackupJsonAct @Inject constructor( + private val accountDao: AccountDao, + private val accountFolderDao: AccountFolderDao, + private val categoryDao: CategoryDao, + private val transactionDao: TransactionDao, + private val trnTagDao: TrnTagDao, + private val tagDao: TagDao, + private val trnLinkRecordDao: TrnLinkRecordDao, + private val trnMetadataDao: TrnMetadataDao, + private val attachmentDao: AttachmentDao, + private val exchangeRateOverrideDao: ExchangeRateOverrideDao, + private val dataStore: IvyDataStore, + private val settingsKeys: SettingsKeys, + private val timeProvider: TimeProvider, +) : Action() { + + // it's only CPU work, computational work => use Dispatchers.Default instead of IO + override fun dispatcher() = Dispatchers.Default + + override suspend fun action(input: Unit): JSONObject { + return coroutineScope { + val accounts = async { exportAccountsJson(accountDao) } + val accountFolders = async { exportAccountFoldersJson(accountFolderDao) } + val categories = async { exportCategoriesJson(categoryDao) } + val tags = async { exportTagsToJson(tagDao) } + val attachments = async { exportAttachmentsJson(attachmentDao) } + val transactions = async { exportTransactionsJson(transactionDao) } + val trnMetadata = async { exportTrnMetadataJson(trnMetadataDao) } + val trnLinks = async { exportTrnLinkRecordsJson(trnLinkRecordDao) } + val trnTags = async { exportTrnTagsJson(trnTagDao) } + val exchangeRatesOverrides = + async { exportExchangeRatesOverridesToJson(exchangeRateOverrideDao) } + val settings = async { exportSettingsToJson(dataStore, settingsKeys) } + + JSONObject().apply { + put("backupInfo", backupInfo()) + put("accounts", accounts.await()) + put("accountFolders", accountFolders.await()) + put("categories", categories.await()) + put("tags", tags.await()) + put("attachments", attachments.await()) + put("transactions", transactions.await()) + put("trnMetadata", trnMetadata.await()) + put("trnLinks", trnLinks.await()) + put("trnTags", trnTags.await()) + put("exchangeRatesOverrides", exchangeRatesOverrides.await()) + put("settings", settings.await()) + } + } + } + + private fun backupInfo(): JSONObject { + return JSONObject().apply { + put("version", 1) + put("timestamp", timeProvider.timeNow().toUtc(timeProvider).epochSecond) + } + } +} \ No newline at end of file diff --git a/backup/impl/src/main/java/com/ivy/impl/export/json/ExportCategoriesData.kt b/backup/impl/src/main/java/com/ivy/impl/export/json/ExportCategoriesData.kt new file mode 100644 index 0000000..208ed9f --- /dev/null +++ b/backup/impl/src/main/java/com/ivy/impl/export/json/ExportCategoriesData.kt @@ -0,0 +1,21 @@ +package com.ivy.impl.export.json + +import com.ivy.core.persistence.dao.category.CategoryDao +import org.json.JSONArray + +internal suspend fun exportCategoriesJson( + categoryDao: CategoryDao +): JSONArray = exportJson( + findAll = categoryDao::findAllBlocking, + json = { + put("id", it.id) + put("name", it.name) + put("color", it.color) + put("icon", it.color) + put("orderNum", it.orderNum) + put("parentCategoryId", it.parentCategoryId) + put("type", it.type.code) + put("state", it.state.code) + putSync(it.sync, it.lastUpdated) + } +) \ No newline at end of file diff --git a/backup/impl/src/main/java/com/ivy/impl/export/json/ExportExchangeRateOverrides.kt b/backup/impl/src/main/java/com/ivy/impl/export/json/ExportExchangeRateOverrides.kt new file mode 100644 index 0000000..2d7a5fd --- /dev/null +++ b/backup/impl/src/main/java/com/ivy/impl/export/json/ExportExchangeRateOverrides.kt @@ -0,0 +1,16 @@ +package com.ivy.impl.export.json + +import com.ivy.core.persistence.dao.exchange.ExchangeRateOverrideDao +import org.json.JSONArray + +internal suspend fun exportExchangeRatesOverridesToJson( + exchangeRateOverrideDao: ExchangeRateOverrideDao +): JSONArray = exportJson( + findAll = exchangeRateOverrideDao::findAllBlocking, + json = { + put("baseCurrency", it.baseCurrency) + put("currency", it.currency) + put("rate", it.rate) + putSync(it.sync, it.lastUpdated) + } +) \ No newline at end of file diff --git a/backup/impl/src/main/java/com/ivy/impl/export/json/ExportJsonUtil.kt b/backup/impl/src/main/java/com/ivy/impl/export/json/ExportJsonUtil.kt new file mode 100644 index 0000000..fb31690 --- /dev/null +++ b/backup/impl/src/main/java/com/ivy/impl/export/json/ExportJsonUtil.kt @@ -0,0 +1,25 @@ +package com.ivy.impl.export.json + +import com.ivy.data.SyncState +import org.json.JSONArray +import org.json.JSONObject +import java.time.Instant + +internal suspend fun exportJson( + findAll: suspend () -> List, + json: JSONObject.(T) -> Unit, +): JSONArray { + val jsonArr = JSONArray() + findAll().forEach { + jsonArr.put(JSONObject().apply { json(it) }) + } + return jsonArr +} + +internal fun JSONObject.putSync( + syncState: SyncState, + lastUpdated: Instant +) { + put("syncState", syncState.code) + put("lastUpdated", lastUpdated.epochSecond) +} \ No newline at end of file diff --git a/backup/impl/src/main/java/com/ivy/impl/export/json/ExportSettings.kt b/backup/impl/src/main/java/com/ivy/impl/export/json/ExportSettings.kt new file mode 100644 index 0000000..f6c4c2f --- /dev/null +++ b/backup/impl/src/main/java/com/ivy/impl/export/json/ExportSettings.kt @@ -0,0 +1,28 @@ +package com.ivy.impl.export.json + +import androidx.datastore.preferences.core.Preferences +import com.ivy.core.persistence.datastore.IvyDataStore +import com.ivy.core.persistence.datastore.keys.SettingsKeys +import kotlinx.coroutines.flow.firstOrNull +import org.json.JSONObject + +internal suspend fun exportSettingsToJson( + dataStore: IvyDataStore, + settingsKeys: SettingsKeys +): JSONObject { + suspend fun JSONObject.add( + key: Preferences.Key, + ) { + dataStore.get(key).firstOrNull()?.let { + put(key.name, it) + } + } + + val json = JSONObject() + json.add(settingsKeys.baseCurrency) + json.add(settingsKeys.theme) + json.add(settingsKeys.startDayOfMonth) + json.add(settingsKeys.displayName) + json.add(settingsKeys.hideBalance) + return json +} \ No newline at end of file diff --git a/backup/impl/src/main/java/com/ivy/impl/export/json/ExportTags.kt b/backup/impl/src/main/java/com/ivy/impl/export/json/ExportTags.kt new file mode 100644 index 0000000..1de08aa --- /dev/null +++ b/backup/impl/src/main/java/com/ivy/impl/export/json/ExportTags.kt @@ -0,0 +1,18 @@ +package com.ivy.impl.export.json + +import com.ivy.core.persistence.dao.tag.TagDao +import org.json.JSONArray + +internal suspend fun exportTagsToJson( + tagDao: TagDao +): JSONArray = exportJson( + findAll = tagDao::findAllBlocking, + json = { + put("id", it.id) + put("color", it.color) + put("name", it.name) + put("orderNum", it.orderNum) + put("state", it.state.code) + putSync(it.sync, it.lastUpdated) + } +) \ No newline at end of file diff --git a/backup/impl/src/main/java/com/ivy/impl/export/json/ExportTransactionsData.kt b/backup/impl/src/main/java/com/ivy/impl/export/json/ExportTransactionsData.kt new file mode 100644 index 0000000..96452c7 --- /dev/null +++ b/backup/impl/src/main/java/com/ivy/impl/export/json/ExportTransactionsData.kt @@ -0,0 +1,64 @@ +package com.ivy.impl.export.json + +import com.ivy.core.persistence.dao.trn.TransactionDao +import com.ivy.core.persistence.dao.trn.TrnLinkRecordDao +import com.ivy.core.persistence.dao.trn.TrnMetadataDao +import com.ivy.core.persistence.dao.trn.TrnTagDao +import org.json.JSONArray + +internal suspend fun exportTransactionsJson( + transactionDao: TransactionDao +): JSONArray = exportJson( + findAll = transactionDao::findAllBlocking, + json = { + put("id", it.id) + put("accountId", it.accountId) + put("type", it.type.code) + put("amount", it.amount) + put("currency", it.currency) + put("time", it.time.epochSecond) + put("timeType", it.timeType.code) + put("title", it.title) + put("description", it.description) + put("categoryId", it.categoryId) + put("state", it.state) + put("purpose", it.purpose?.code) + putSync(it.sync, it.lastUpdated) + } +) + +internal suspend fun exportTrnMetadataJson( + trnMetadataDao: TrnMetadataDao +): JSONArray = exportJson( + findAll = trnMetadataDao::findAllBlocking, + json = { + put("id", it.id) + put("trnId", it.trnId) + put("key", it.key) + put("value", it.value) + putSync(it.sync, it.lastUpdated) + } +) + +internal suspend fun exportTrnLinkRecordsJson( + trnLinkRecordDao: TrnLinkRecordDao +): JSONArray = exportJson( + findAll = trnLinkRecordDao::findAllBlocking, + json = { + put("id", it.id) + put("trnId", it.trnId) + put("batchId", it.batchId) + putSync(it.sync, it.lastUpdated) + } +) + +internal suspend fun exportTrnTagsJson( + trnTagDao: TrnTagDao +): JSONArray = exportJson( + findAll = trnTagDao::findAllBlocking, + json = { + put("trnId", it.trnId) + put("tagId", it.tagId) + putSync(it.sync, it.lastUpdated) + } +) \ No newline at end of file diff --git a/backup/impl/src/main/java/com/ivy/impl/load/ParseAccountsData.kt b/backup/impl/src/main/java/com/ivy/impl/load/ParseAccountsData.kt new file mode 100644 index 0000000..7f984ff --- /dev/null +++ b/backup/impl/src/main/java/com/ivy/impl/load/ParseAccountsData.kt @@ -0,0 +1,62 @@ +package com.ivy.impl.load + +import arrow.core.Either +import com.ivy.backup.base.ImportBackupError +import com.ivy.backup.base.maybe +import com.ivy.backup.base.parseItems +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.toUUID +import com.ivy.data.account.Account +import com.ivy.data.account.AccountFolder +import com.ivy.data.account.AccountState +import org.json.JSONObject + +// region Accounts +internal fun parseAccounts( + json: JSONObject, + timeProvider: TimeProvider, +): Either> = + parseItems( + json = json, + key = "accounts", + error = ImportBackupError.Parse::Accounts, + parse = { + Account( + id = getString("id").toUUID(), + name = getString("name"), + currency = getString("currency"), + color = getInt("color"), + icon = maybe { getString("icon") }, + excluded = getBoolean("excluded"), + folderId = maybe { getString("folderId").toUUID() }, + orderNum = getDouble("orderNum"), + state = getInt("state").let(AccountState::fromCode) + ?: AccountState.Default, + sync = parseSync(timeProvider) + ) + } + ) + +// endregion + +// region Account Folders +internal fun parseAccountFolders( + json: JSONObject, + timeProvider: TimeProvider, +): Either> = + parseItems( + json = json, + key = "accountFolders", + error = ImportBackupError.Parse::AccountFolders, + parse = { + AccountFolder( + id = getString("id"), + name = getString("name"), + color = getInt("color"), + icon = getString("icon"), + orderNum = getDouble("orderNum"), + sync = parseSync(timeProvider) + ) + } + ) +// endregion \ No newline at end of file diff --git a/backup/impl/src/main/java/com/ivy/impl/load/ParseAttachments.kt b/backup/impl/src/main/java/com/ivy/impl/load/ParseAttachments.kt new file mode 100644 index 0000000..9fd35eb --- /dev/null +++ b/backup/impl/src/main/java/com/ivy/impl/load/ParseAttachments.kt @@ -0,0 +1,33 @@ +package com.ivy.impl.load + +import arrow.core.Either +import com.ivy.backup.base.ImportBackupError +import com.ivy.backup.base.maybe +import com.ivy.backup.base.parseItems +import com.ivy.common.time.provider.TimeProvider +import com.ivy.data.attachment.Attachment +import com.ivy.data.attachment.AttachmentSource +import com.ivy.data.attachment.AttachmentType +import org.json.JSONObject + +internal fun parseAttachments( + json: JSONObject, + timeProvider: TimeProvider +): Either> = + parseItems( + json = json, + key = "attachments", + error = ImportBackupError.Parse::Attachments, + parse = { + Attachment( + id = getString("id"), + associatedId = getString("associatedId"), + uri = getString("uri"), + source = getInt("source").let(AttachmentSource::fromCode) + ?: error("Invalid attachment code - ${getInt("source")}!"), + filename = maybe { getString("filename") }, + type = maybe { getInt("type").let(AttachmentType::fromCode) }, + sync = parseSync(timeProvider), + ) + } + ) \ No newline at end of file diff --git a/backup/impl/src/main/java/com/ivy/impl/load/ParseJsonUtil.kt b/backup/impl/src/main/java/com/ivy/impl/load/ParseJsonUtil.kt new file mode 100644 index 0000000..edcfcbd --- /dev/null +++ b/backup/impl/src/main/java/com/ivy/impl/load/ParseJsonUtil.kt @@ -0,0 +1,20 @@ +package com.ivy.impl.load + +import com.ivy.backup.base.parseDateTime +import com.ivy.common.time.provider.TimeProvider +import com.ivy.data.Sync +import com.ivy.data.SyncState +import org.json.JSONObject +import java.time.LocalDateTime + +internal fun JSONObject.parseSync( + timeProvider: TimeProvider +): Sync = Sync( + state = getInt("syncState").let(SyncState::fromCode) ?: SyncState.Syncing, + lastUpdated = parseLastUpdated(timeProvider) +) + +internal fun JSONObject.parseLastUpdated( + timeProvider: TimeProvider +): LocalDateTime = + parseDateTime("lastUpdated", timeProvider) ?: timeProvider.timeNow() \ No newline at end of file diff --git a/backup/impl/src/main/java/com/ivy/impl/load/ParseV1JsonDataAct.kt b/backup/impl/src/main/java/com/ivy/impl/load/ParseV1JsonDataAct.kt new file mode 100644 index 0000000..2be6a3d --- /dev/null +++ b/backup/impl/src/main/java/com/ivy/impl/load/ParseV1JsonDataAct.kt @@ -0,0 +1,25 @@ +package com.ivy.impl.load + +import arrow.core.Either +import arrow.core.computations.either +import com.ivy.backup.base.ImportBackupError +import com.ivy.backup.base.data.BackupData +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.domain.action.Action +import kotlinx.coroutines.Dispatchers +import org.json.JSONObject +import javax.inject.Inject + +class ParseV1JsonDataAct @Inject constructor( + private val timeProvider: TimeProvider, +) : Action>() { + override fun dispatcher() = Dispatchers.Default + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(json: JSONObject): Either = + either { + val now = timeProvider.timeNow() + TODO() + } + +} \ No newline at end of file diff --git a/backup/old/.gitignore b/backup/old/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/backup/old/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/backup/old/README.md b/backup/old/README.md new file mode 100644 index 0000000..9e9f9a4 --- /dev/null +++ b/backup/old/README.md @@ -0,0 +1,3 @@ +# Backup Old + +Its purpose is only to be able to import Backup JSON .zip from the old Ivy Wallet app. \ No newline at end of file diff --git a/backup/old/build.gradle.kts b/backup/old/build.gradle.kts new file mode 100644 index 0000000..e5e34a6 --- /dev/null +++ b/backup/old/build.gradle.kts @@ -0,0 +1,20 @@ +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":core:domain")) + implementation(project(":core:data-model")) + implementation(project(":core:persistence")) + api(project(":backup:base")) + implementation(project(":android:file-system")) + Testing() +} \ No newline at end of file diff --git a/backup/old/src/main/AndroidManifest.xml b/backup/old/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f0176ba --- /dev/null +++ b/backup/old/src/main/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/backup/old/src/main/java/com/ivy/old/ImportOldJsonBackupAct.kt b/backup/old/src/main/java/com/ivy/old/ImportOldJsonBackupAct.kt new file mode 100644 index 0000000..8bda159 --- /dev/null +++ b/backup/old/src/main/java/com/ivy/old/ImportOldJsonBackupAct.kt @@ -0,0 +1,58 @@ +package com.ivy.old + +import android.content.Context +import android.net.Uri +import arrow.core.Either +import arrow.core.computations.either +import com.ivy.backup.base.ImportBackupError +import com.ivy.backup.base.OnImportProgress +import com.ivy.backup.base.WriteBackupDataAct +import com.ivy.backup.base.data.ImportResult +import com.ivy.backup.base.extractBackupJson +import com.ivy.core.domain.action.Action +import com.ivy.old.parse.ParseOldJsonAct +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class ImportOldJsonBackupAct @Inject constructor( + @ApplicationContext + private val context: Context, + private val parseOldJsonAct: ParseOldJsonAct, + private val writeBackupDataAct: WriteBackupDataAct, +) : Action>() { + data class Input( + val backupZipPath: Uri, + val onProgress: OnImportProgress, + ) + + override suspend fun action(input: Input): Either = + either { + val progress = { percent: Float, message: String -> + input.onProgress.onProgress(percent, message) + } + + progress(1f, "Unzipping backup JSON...") + val backupJson = extractBackupJson(context, input.backupZipPath).bind() + val backupData = parseOldJsonAct(backupJson).bind() + // endregion + + progress(10f, "Backup JSON parsed. Saving to database...") + writeBackupDataAct( + WriteBackupDataAct.Input( + backup = backupData, + onProgress = object : OnImportProgress { + override fun onProgress(percent: Float, message: String) { + // Adjust from 13% to 100% + val adjustedPercent = 0.13f + (0.87f * percent) + progress(adjustedPercent, message) + } + } + ) + ) + + ImportResult( + faultyTransfers = backupData.transfers.faulty + ) + } +} + diff --git a/backup/old/src/main/java/com/ivy/old/parse/ParseAccounts.kt b/backup/old/src/main/java/com/ivy/old/parse/ParseAccounts.kt new file mode 100644 index 0000000..82d42eb --- /dev/null +++ b/backup/old/src/main/java/com/ivy/old/parse/ParseAccounts.kt @@ -0,0 +1,43 @@ +package com.ivy.old.parse + +import arrow.core.Either +import com.ivy.backup.base.ImportBackupError +import com.ivy.backup.base.maybe +import com.ivy.backup.base.parseItems +import com.ivy.common.toUUID +import com.ivy.data.Sync +import com.ivy.data.SyncState +import com.ivy.data.account.Account +import com.ivy.data.account.AccountState +import org.json.JSONObject +import java.time.LocalDateTime + +internal fun parseAccounts( + json: JSONObject, + now: LocalDateTime +): Either> = parseItems( + json = json, + key = "accounts", + error = ImportBackupError.Parse::Accounts, + parse = { + parseAccount(now) + } +) + +private fun JSONObject.parseAccount( + now: LocalDateTime +): Account = Account( + id = getString("id").toUUID(), + name = getString("name"), + currency = getString("currency"), + color = getInt("color"), + icon = maybe { getString("icon") }, + excluded = getBoolean("includeInBalance").not(), + folderId = null, + orderNum = getDouble("orderNum"), + state = AccountState.Default, + sync = Sync( + state = SyncState.Syncing, + lastUpdated = now + ) +) \ No newline at end of file diff --git a/backup/old/src/main/java/com/ivy/old/parse/ParseCategories.kt b/backup/old/src/main/java/com/ivy/old/parse/ParseCategories.kt new file mode 100644 index 0000000..b40c447 --- /dev/null +++ b/backup/old/src/main/java/com/ivy/old/parse/ParseCategories.kt @@ -0,0 +1,43 @@ +package com.ivy.old.parse + +import arrow.core.Either +import com.ivy.backup.base.ImportBackupError +import com.ivy.backup.base.maybe +import com.ivy.backup.base.parseItems +import com.ivy.common.toUUID +import com.ivy.data.Sync +import com.ivy.data.SyncState +import com.ivy.data.category.Category +import com.ivy.data.category.CategoryState +import com.ivy.data.category.CategoryType +import org.json.JSONObject +import java.time.LocalDateTime + +internal fun parseCategories( + json: JSONObject, + now: LocalDateTime +): Either> = parseItems( + json = json, + key = "categories", + error = ImportBackupError.Parse::Categories, + parse = { + parseCategory(now) + } +) + +private fun JSONObject.parseCategory( + now: LocalDateTime +): Category = Category( + id = getString("id").toUUID(), + name = getString("name"), + type = CategoryType.Both, + parentCategoryId = null, + orderNum = getDouble("orderNum"), + color = getInt("color"), + icon = maybe { getString("icon") }, + state = CategoryState.Default, + sync = Sync( + state = SyncState.Syncing, + lastUpdated = now + ) +) \ No newline at end of file diff --git a/backup/old/src/main/java/com/ivy/old/parse/ParseOldJsonAct.kt b/backup/old/src/main/java/com/ivy/old/parse/ParseOldJsonAct.kt new file mode 100644 index 0000000..b88c71f --- /dev/null +++ b/backup/old/src/main/java/com/ivy/old/parse/ParseOldJsonAct.kt @@ -0,0 +1,57 @@ +package com.ivy.old.parse + +import arrow.core.Either +import arrow.core.computations.either +import com.ivy.backup.base.ImportBackupError +import com.ivy.backup.base.data.BackupData +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.domain.action.Action +import com.ivy.data.transaction.* +import kotlinx.coroutines.Dispatchers +import org.json.JSONObject +import java.util.* +import javax.inject.Inject + +class ParseOldJsonAct @Inject constructor( + private val timeProvider: TimeProvider, +) : Action>() { + override fun dispatcher() = Dispatchers.Default + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(json: JSONObject): Either = either { + val now = timeProvider.timeNow() + val accounts = parseAccounts(json, now).bind() + val categories = parseCategories(json, now).bind() + + val accountsMap = accounts.associateBy { it.id.toString() } + val categoriesMap = categories.associateBy { it.id.toString() } + val transactions = parseTransactions( + json = json, + now = now, + accountsMap = accountsMap, + categoriesMap = categoriesMap, + timeProvider = timeProvider, + ).bind() + val transfersData = parseTransfers( + json = json, + now = now, + accountsMap = accountsMap, + categoriesMap = categoriesMap, + timeProvider = timeProvider, + ).bind() + val settings = parseSettings(json).bind() + + BackupData( + accounts = accounts, + categories = categories, + transactions = transactions + transfersData.partlyCorrupted, + transfers = transfersData.transfers, + + accountFolders = null, + tags = null, + attachments = null, + + settings = settings, + ) + } +} \ No newline at end of file diff --git a/backup/old/src/main/java/com/ivy/old/parse/ParseSettings.kt b/backup/old/src/main/java/com/ivy/old/parse/ParseSettings.kt new file mode 100644 index 0000000..07ec602 --- /dev/null +++ b/backup/old/src/main/java/com/ivy/old/parse/ParseSettings.kt @@ -0,0 +1,26 @@ +package com.ivy.old.parse + +import arrow.core.Either +import com.ivy.backup.base.ImportBackupError +import com.ivy.backup.base.data.SettingsData +import com.ivy.backup.base.maybe +import com.ivy.data.Theme +import org.json.JSONObject + +fun parseSettings( + json: JSONObject +): Either = Either.catch( + ImportBackupError.Parse::Settings +) { + val settingsJson = json.getJSONArray("settings") + .getJSONObject(0) + + SettingsData( + baseCurrency = settingsJson.getString("currency"), + theme = when (maybe { settingsJson.get("theme") }) { + "DARK" -> Theme.Dark + "LIGHT" -> Theme.Light + else -> Theme.Auto + } + ) +} \ No newline at end of file diff --git a/backup/old/src/main/java/com/ivy/old/parse/ParseTransactions.kt b/backup/old/src/main/java/com/ivy/old/parse/ParseTransactions.kt new file mode 100644 index 0000000..a345f5b --- /dev/null +++ b/backup/old/src/main/java/com/ivy/old/parse/ParseTransactions.kt @@ -0,0 +1,88 @@ +package com.ivy.old.parse + +import arrow.core.Either +import com.ivy.backup.base.ImportBackupError +import com.ivy.backup.base.maybe +import com.ivy.backup.base.optionalUUID +import com.ivy.backup.base.parseTrnTime +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.toUUID +import com.ivy.data.Sync +import com.ivy.data.SyncState +import com.ivy.data.Value +import com.ivy.data.account.Account +import com.ivy.data.category.Category +import com.ivy.data.transaction.Transaction +import com.ivy.data.transaction.TransactionType +import com.ivy.data.transaction.TrnMetadata +import com.ivy.data.transaction.TrnState +import org.json.JSONObject +import java.time.LocalDateTime + +internal fun parseTransactions( + json: JSONObject, + now: LocalDateTime, + accountsMap: Map, + categoriesMap: Map, + timeProvider: TimeProvider, +): Either> = + Either.catch(ImportBackupError.Parse::Transactions) { + val transactionsJson = json.getJSONArray("transactions") + val transactions = mutableListOf() + for (i in 0 until transactionsJson.length()) { + val trnJson = transactionsJson.getJSONObject(i) + if (trnJson.getString("type") == "TRANSFER") + continue // skip transfers + transactions.add( + trnJson.parseTransaction( + now = now, + accountsMap = accountsMap, + categoriesMap = categoriesMap, + timeProvider = timeProvider, + ) + ) + } + transactions + } + +private fun JSONObject.parseTransaction( + now: LocalDateTime, + accountsMap: Map, + categoriesMap: Map, + timeProvider: TimeProvider, +): Transaction { + val account = accountsMap[getString("accountId")] + ?: error("Account with id ${getString("accountId")} not found") + + return Transaction( + id = getString("id").toUUID(), + account = account, + type = when (val type = getString("type")) { + "INCOME" -> TransactionType.Income + "EXPENSE" -> TransactionType.Expense + else -> error("Unknown transaction type: $type") + }, + value = Value( + amount = getDouble("amount"), + currency = account.currency, + ), + category = maybe { categoriesMap[getString("categoryId")] }, + time = parseTrnTime(this, timeProvider = timeProvider), + title = maybe { getString("title") }, + description = maybe { getString("description") }, + state = TrnState.Default, + purpose = null, + tags = emptyList(), + attachments = emptyList(), + metadata = TrnMetadata( + recurringRuleId = optionalUUID("recurringRuleId"), + loanId = optionalUUID("loanId"), + loanRecordId = optionalUUID("loanRecordId"), + ), + sync = Sync( + state = SyncState.Syncing, + lastUpdated = now + ), + ) +} +// endregion diff --git a/backup/old/src/main/java/com/ivy/old/parse/ParseTransfers.kt b/backup/old/src/main/java/com/ivy/old/parse/ParseTransfers.kt new file mode 100644 index 0000000..58ab02e --- /dev/null +++ b/backup/old/src/main/java/com/ivy/old/parse/ParseTransfers.kt @@ -0,0 +1,173 @@ +package com.ivy.old.parse + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.ivy.backup.base.ImportBackupError +import com.ivy.backup.base.data.BatchTransferData +import com.ivy.backup.base.data.FaultTolerantList +import com.ivy.backup.base.maybe +import com.ivy.backup.base.parseTrnTime +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.toUUID +import com.ivy.core.domain.action.transaction.transfer.TransferData +import com.ivy.data.Sync +import com.ivy.data.SyncState +import com.ivy.data.Value +import com.ivy.data.account.Account +import com.ivy.data.category.Category +import com.ivy.data.transaction.Transaction +import com.ivy.data.transaction.TransactionType +import com.ivy.data.transaction.TrnMetadata +import com.ivy.data.transaction.TrnState +import org.json.JSONObject +import java.time.LocalDateTime + +data class TransfersData( + val transfers: FaultTolerantList, + val partlyCorrupted: List, +) + +internal fun parseTransfers( + json: JSONObject, + now: LocalDateTime, + accountsMap: Map, + categoriesMap: Map, + timeProvider: TimeProvider, +): Either = + Either.catch(ImportBackupError.Parse::Transfers) { + val transactionsJson = json.getJSONArray("transactions") + val transfers = mutableListOf() + val partlyCorrupted = mutableListOf() + + var corrupted = 0 + for (i in 0 until transactionsJson.length()) { + val trnJson = transactionsJson.getJSONObject(i) + if (trnJson.getString("type") != "TRANSFER") + continue // skip non-transfers + + val eitherTransfer = trnJson.parseTransfer( + now = now, + accountsMap = accountsMap, + categoriesMap = categoriesMap, + timeProvider = timeProvider, + ) + if (eitherTransfer != null) { + when (eitherTransfer) { + is Either.Left -> partlyCorrupted.add(eitherTransfer.value) + is Either.Right -> transfers.add(eitherTransfer.value) + } + } else { + corrupted++ + } + + } + + TransfersData( + transfers = FaultTolerantList(items = transfers, faulty = corrupted), + partlyCorrupted = partlyCorrupted, + ) + } + +private fun JSONObject.parseTransfer( + now: LocalDateTime, + accountsMap: Map, + categoriesMap: Map, + timeProvider: TimeProvider, +): Either? = maybe { + val oldTrnId = getString("id") + val accountFrom = accountsMap[getString("accountId")] + val accountTo = accountsMap[getString("toAccountId")] + + val fromAmount = getDouble("amount") + val toAmount = maybe { getDouble("toAmount") } ?: fromAmount + + val category = maybe { categoriesMap[getString("categoryId")] } + val title = maybe { getString("title") } + val description = maybe { getString("description") } + val trnTime = parseTrnTime(this, timeProvider = timeProvider) + val sync = Sync( + state = SyncState.Syncing, + lastUpdated = now + ) + + when { + accountFrom != null && accountTo != null -> { + BatchTransferData( + batchId = oldTrnId, + transfer = TransferData( + amountFrom = Value( + amount = fromAmount, + currency = accountFrom.currency, + ), + amountTo = Value( + amount = toAmount, + currency = accountTo.currency, + ), + accountFrom = accountFrom, + accountTo = accountTo, + category = category, + time = trnTime, + title = title, + description = description, + fee = null, + sync = sync, + ) + ).right() + } + accountFrom != null && accountTo == null -> { + // Expense (money sent to the void) + Transaction( + id = oldTrnId.toUUID(), + type = TransactionType.Expense, + value = Value( + amount = fromAmount, + currency = accountFrom.currency, + ), + account = accountFrom, + title = title, + description = description, + category = category, + time = trnTime, + state = TrnState.Default, + purpose = null, + tags = emptyList(), + attachments = emptyList(), + metadata = TrnMetadata( + recurringRuleId = null, + loanId = null, + loanRecordId = null, + ), + sync = sync, + ).left() + } + accountFrom == null && accountTo != null -> { + // Income (money coming from the void) + Transaction( + id = oldTrnId.toUUID(), + type = TransactionType.Income, + value = Value( + amount = toAmount, + currency = accountTo.currency, + ), + account = accountTo, + title = title, + description = description, + category = category, + time = trnTime, + state = TrnState.Default, + purpose = null, + tags = emptyList(), + attachments = emptyList(), + metadata = TrnMetadata( + recurringRuleId = null, + loanId = null, + loanRecordId = null, + ), + sync = sync, + ).left() + } + else -> error("Corrupted transfer JSON: $this") + } + +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..a7847eb --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,47 @@ +import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask + +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + id("android-reporting") + // Run with: + // ./gradlew dependencyUpdates // Simple report in the console + // ./gradlew dependencyUpdates -DoutputFormatter=html,json,xml // Report in console & generate files accordingly + id("com.github.ben-manes.versions") version "0.39.0" + + // Kotest Plugin + // https://github.com/kotest/kotest-gradle-plugin + id("io.kotest") version "0.3.8" +} + +tasks { + register("clean", Delete::class) { + delete(rootProject.buildDir) + } + + withType { + rejectVersionIf { + isNonStable(candidate.version) + } + } +} + +tasks.withType().configureEach { + useJUnitPlatform() +} + +// Any of parameter of this task can be passed on or changed when running the gradle task as parameter +tasks.named("dependencyUpdates").configure { + outputFormatter = "html" + outputDir = "build/reports/dependencyUpdates" + reportfileName = "report" +} + +// https://github.com/ben-manes/gradle-versions-plugin#rejectversionsif-and-componentselection +// This has been tested thoroughly by community +fun isNonStable(version: String): Boolean { + val stableKeyword = + listOf("RELEASE", "FINAL", "GA", "RC").any { version.toUpperCase().contains(it) } + val regex = "^[0-9,.v-]+(-r)?$".toRegex() + val isStable = stableKeyword || regex.matches(version) + return isStable.not() +} \ No newline at end of file diff --git a/buildSrc/README.md b/buildSrc/README.md new file mode 100644 index 0000000..b60de1c --- /dev/null +++ b/buildSrc/README.md @@ -0,0 +1,3 @@ +# buildSrc + +Root build module containing all dependencies used in the Ivy Wallet project in `dependencies.kt`. \ No newline at end of file diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..f3e7043 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + `kotlin-dsl` +} + +repositories { + google() + mavenCentral() + maven(url = "https://jitpack.io") +} + +dependencies { + //https://mvnrepository.com/artifact/com.android.tools.build/gradle?repo=google + implementation("com.android.tools.build:gradle:7.4.0-rc01") + + //https://kotlinlang.org/docs/releases.html#release-details + // Must match kotlinVersion from dependencies.kt + // Warning: KSP must match Kotlin's version + val kotlinVersion = "1.7.20" + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + implementation(kotlin("serialization", version = kotlinVersion)) + implementation("com.google.devtools.ksp:symbol-processing-gradle-plugin:1.7.20-1.0.8") + + //https://developer.android.com/training/dependency-injection/hilt-android + // Must match hiltVersion from dependencies.kt + implementation("com.google.dagger:hilt-android-gradle-plugin:2.44") + + //URL: https://developers.google.com/android/guides/google-services-plugin + implementation("com.google.gms:google-services:4.3.13") + + //https://www.mongodb.com/docs/realm/sdk/kotlin/install/android/ + // Must match Versions.realm from dependencies.kt +// implementation("io.realm.kotlin:gradle-plugin:1.0.2") + + implementation("com.google.firebase:firebase-crashlytics-gradle:2.9.1") +} \ No newline at end of file diff --git a/buildSrc/src/main/java/com/ivy/buildsrc/DependencyHandlerExt.kt b/buildSrc/src/main/java/com/ivy/buildsrc/DependencyHandlerExt.kt new file mode 100644 index 0000000..0d44714 --- /dev/null +++ b/buildSrc/src/main/java/com/ivy/buildsrc/DependencyHandlerExt.kt @@ -0,0 +1,82 @@ +package com.ivy.buildsrc + +import org.gradle.api.artifacts.dsl.DependencyHandler + +internal fun DependencyHandler.dependency(dependency: Any, api: Boolean) { + if (api) api(dependency) else implementation(dependency) +} + +internal fun DependencyHandler.debugDependency(dependency: Any, api: Boolean) { + if (api) debugApi(dependency) else debugImplementation(dependency) +} + +internal fun DependencyHandler.testDependency(dependency: Any, api: Boolean) { + if (api) testApi(dependency) else testImplementation(dependency) +} + +internal fun DependencyHandler.androidTestDependency(dependency: Any, api: Boolean) { + if (api) androidTestApi(dependency) else androidTestImplementation(dependency) +} + +// ---------------------------------------------------------------------------------- + +internal fun DependencyHandler.implementation(dependency: Any) { + this.add("implementation", dependency) +} + +internal fun DependencyHandler.debugImplementation(dependency: Any) { + this.add("debugImplementation", dependency) +} + +internal fun DependencyHandler.implementation(value: String) { + this.implementation(dependency = value) +} + +internal fun DependencyHandler.api(dependency: Any) { + this.add("api", dependency) +} + +internal fun DependencyHandler.debugApi(dependency: Any) { + this.add("debugApi", dependency) +} + +internal fun DependencyHandler.api(value: String) { + this.api(dependency = value) +} + +internal fun DependencyHandler.kapt(dependency: Any) { + this.add("kapt", dependency) +} + +fun DependencyHandler.kapt(value: String) { + this.kapt(dependency = value) +} + +fun DependencyHandler.ksp(value: String) { + this.ksp(dependency = value) +} + +internal fun DependencyHandler.ksp(dependency: Any) { + this.add("ksp", dependency) +} + +internal fun DependencyHandler.testImplementation(value: Any) { + this.add("testImplementation", value) +} + +internal fun DependencyHandler.testApi(value: Any) { + this.add("testApi", value) +} + +internal fun DependencyHandler.androidTestImplementation(value: Any) { + this.add("androidTestImplementation", value) +} + +internal fun DependencyHandler.androidTestApi(value: Any) { + this.add("androidTestApi", value) +} + +internal fun DependencyHandler.kaptAndroidTest(value: Any) { + this.add("kaptAndroidTest", value) +} + diff --git a/buildSrc/src/main/java/com/ivy/buildsrc/IvyComposePlugin.kt b/buildSrc/src/main/java/com/ivy/buildsrc/IvyComposePlugin.kt new file mode 100644 index 0000000..6723d45 --- /dev/null +++ b/buildSrc/src/main/java/com/ivy/buildsrc/IvyComposePlugin.kt @@ -0,0 +1,19 @@ +package com.ivy.buildsrc + +import org.gradle.api.Project + +abstract class IvyComposePlugin : IvyPlugin() { + + override fun apply(project: Project) { + super.apply(project) + + val library = project.androidLibrary() + library.composeOptions { + kotlinCompilerExtensionVersion = Versions.composeCompilerVersion + } + library.buildFeatures { + compose = true + } + + } +} \ No newline at end of file diff --git a/buildSrc/src/main/java/com/ivy/buildsrc/IvyPlugin.kt b/buildSrc/src/main/java/com/ivy/buildsrc/IvyPlugin.kt new file mode 100644 index 0000000..7ef25cd --- /dev/null +++ b/buildSrc/src/main/java/com/ivy/buildsrc/IvyPlugin.kt @@ -0,0 +1,114 @@ +package com.ivy.buildsrc + +import com.android.build.api.dsl.LibraryExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +abstract class IvyPlugin : Plugin { + + override fun apply(project: Project) { + applyPlugins(project) + addKotlinCompilerArgs(project) + setProjectSdkVersions(project) + + kotest(project) + // Robolectric doesn't integrate well with JUnit5 and Kotest +// robolectric(project) + androidTest(project) + lint(project) + kspSourceSets(project) + } + + private fun kotest(project: Project) { + val library = project.androidLibrary() + library.testOptions { + unitTests.all { + it.useJUnitPlatform() + } + } + } + +// private fun robolectric(project: Project) { +// project.androidLibrary().testOptions { +// unitTests.isIncludeAndroidResources = true +// } +// } + + private fun androidTest(project: Project) { + project.androidLibrary().defaultConfig { + testInstrumentationRunner = "com.ivy.common.androidtest.IvyTestRunner" + } + } + + /** + * Global lint configuration + */ + private fun lint(project: Project) { + project.androidLibrary().lint { + disable.add("MissingTranslation") + } + } + + private fun applyPlugins(project: Project) { + project.apply { + plugin("android-library") + plugin("kotlin-android") + plugin("kotlin-kapt") + plugin("dagger.hilt.android.plugin") + plugin("io.kotest") + plugin("com.google.devtools.ksp") + + //TODO: Enable if we migrate to kotlinx serialization +// plugin("kotlinx-serialization") + } + } + + private fun kspSourceSets(project: Project) { + project.androidLibrary().sourceSets { + getByName("main").apply { + java.srcDir("build/generated/ksp/debug") + kotlin.srcDir("build/generated/ksp/debug") + java.srcDir("build/generated/ksp/demo") + kotlin.srcDir("build/generated/ksp/demo") + } + getByName("test").apply { + java.srcDir("build/generated/ksp/debug") + kotlin.srcDir("build/generated/ksp/debug") + java.srcDir("build/generated/ksp/demo") + kotlin.srcDir("build/generated/ksp/demo") + } + } + } + + private fun addKotlinCompilerArgs(project: Project) { + project.allprojects { + allprojects { + tasks.withType(KotlinCompile::class).all { + with(kotlinOptions) { + freeCompilerArgs = freeCompilerArgs + listOf("-Xcontext-receivers") + //Suppress Jetpack Compose versions compiler incompatibility, do NOT do it +// freeCompilerArgs = freeCompilerArgs + listOf( +// "-P", +// "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true" +// ) + freeCompilerArgs = freeCompilerArgs + listOf("-Xskip-prerelease-check") + } + } + } + } + } + + private fun setProjectSdkVersions(project: Project) { + val library = project.androidLibrary() + library.compileSdk = com.ivy.buildsrc.Project.compileSdkVersion + library.defaultConfig { + minSdk = com.ivy.buildsrc.Project.minSdk + targetSdk = com.ivy.buildsrc.Project.targetSdk + } + } + + protected fun Project.androidLibrary(): LibraryExtension = + extensions.getByType(LibraryExtension::class.java) +} \ No newline at end of file diff --git a/buildSrc/src/main/java/com/ivy/buildsrc/dependencies.kt b/buildSrc/src/main/java/com/ivy/buildsrc/dependencies.kt new file mode 100644 index 0000000..912c087 --- /dev/null +++ b/buildSrc/src/main/java/com/ivy/buildsrc/dependencies.kt @@ -0,0 +1,563 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * 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 + * + * https://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. + */ + +package com.ivy.buildsrc + +import org.gradle.api.artifacts.dsl.DependencyHandler +import org.gradle.kotlin.dsl.project + + +object Project { + //Version + const val versionName = "4.3.3" + const val versionCode = 117 + + //Compile SDK & Build Tools + const val compileSdkVersion = 33 + + //App + const val applicationId = "com.ivy.wallet" + const val minSdk = 28 + const val targetSdk = 30 +} + +object Versions { + //https://kotlinlang.org/docs/releases.html#release-details + //WARNING: Version must match in buildSrc build.gradle.kts + const val kotlin = "1.7.20" + + //https://github.com/Kotlin/kotlinx.coroutines + const val coroutines = "1.6.4" + + // region Compose + //https://developer.android.com/jetpack/androidx/releases/compose + const val compose = "1.3.2" + + //https://developer.android.com/jetpack/androidx/releases/compose-material + const val composeMaterial = "1.3.1" + + //https://developer.android.com/jetpack/androidx/releases/compose-compiler + const val composeCompilerVersion = "1.3.2" + + //https://developer.android.com/jetpack/androidx/releases/compose-foundation + const val composeFoundation = "1.3.1" + + //https://developer.android.com/jetpack/compose/navigation + const val navigationCompose = "2.5.1" + + //https://developer.android.com/jetpack/androidx/releases/activity + const val composeActivity = "1.6.1" + + //https://developer.android.com/jetpack/androidx/releases/lifecycle + const val composeViewModel = "2.6.0-alpha03" + + //https://developer.android.com/jetpack/androidx/releases/glance + const val composeGlance = "1.0.0-alpha05" + + //Set status bar color + //https://google.github.io/accompanist/systemuicontroller/ + const val composeAccompanistUIController = "0.28.0" + + //https://coil-kt.github.io/coil/compose/ + const val composeCoil = "2.2.2" + // endregion + + //https://arrow-kt.io/docs/quickstart/ + const val arrow: String = "1.1.5" + + //https://kotest.io/ + const val kotest: String = "5.4.2" + + //https://github.com/kotest/kotest-extensions-arrow + const val kotestArrow = "1.3.0" + const val junitJupiter: String = "5.8.2" + + //https://developer.android.com/training/dependency-injection/hilt-android + //WARNING: Update hilt gradle plugin from buildSrc + const val hilt = "2.44" + + //https://mvnrepository.com/artifact/androidx.hilt/hilt-compiler?repo=google + const val hiltX = "1.0.0" + + //https://developer.android.com/jetpack/androidx/releases/hilt + const val hiltNavigationCompose = "1.1.0-alpha01" + + //https://developer.android.com/jetpack/androidx/releases/appcompat + const val appCompat = "1.6.0-rc01" + + //https://developer.android.com/jetpack/androidx/releases/core + const val coreKtx = "1.9.0-alpha05" + + //https://developer.android.com/jetpack/androidx/releases/work + const val workVersion = "2.8.0-alpha02" + + //https://developer.android.com/jetpack/androidx/releases/biometric + const val biometric = "1.2.0-alpha04" + + //https://developer.android.com/jetpack/androidx/releases/recyclerview + const val recyclerView = "1.3.0-beta01" + + //https://developer.android.com/jetpack/androidx/releases/webkit + const val webkit = "1.5.0-beta01" + + //https://developer.android.com/jetpack/androidx/releases/lifecycle + const val lifecycle = "2.6.0-alpha01" + + //https://developer.android.com/jetpack/androidx/releases/room + const val room = "2.5.0" + + //https://github.com/square/retrofit + const val retrofit = "2.9.0" + + //https://ktor.io/ + const val ktor = "2.0.3" + + //https://github.com/google/gson + const val gson = "2.8.7" + + //https://github.com/square/okhttp/tree/master/okhttp-logging-interceptor + const val okhttpLogging = "4.9.1" + + //https://github.com/JakeWharton/timber/releases + const val timber = "4.7.1" + + //https://github.com/greenrobot/EventBus/releases + const val eventBus = "3.2.0" + + //https://developer.android.com/jetpack/androidx/releases/datastore + const val dataStore = "1.0.0" + + //https://developer.android.com/google/play/billing/getting-ready + const val googleBilling = "4.0.0" + + //WARNING: Version must be also updated in buildSrc + //https://www.mongodb.com/docs/realm/sdk/kotlin/install/android/ + const val realm = "1.0.2" + + //https://github.com/Kotlin/kotlinx.serialization#introduction-and-references + const val kotlinSerialization = "1.4.0" + + // region http://robolectric.org/getting-started/ + const val robolectric = "4.8" + const val robolectricJunit = "4.13.2" + + //https://kotest.io/docs/extensions/robolectric.html + const val robolectricKotestExt = "0.5.0" + // endregion + + // region AndroidX Test + //https://developer.android.com/jetpack/androidx/releases/test + const val testCore = "1.4.0" + const val testJunitExt = "1.1.3" + const val testRunner = "1.4.0" + // endregion +} + +fun DependencyHandler.DataStore(api: Boolean) { + dependency("androidx.datastore:datastore-preferences:${Versions.dataStore}", api = api) +} + +/** + * Kotlin STD lib + * https://kotlinlang.org/docs/releases.html#release-details + */ +fun DependencyHandler.Kotlin(api: Boolean) { + //URL: https://kotlinlang.org/docs/releases.html#release-details + //WARNING: Version is also updated from buildSrc + dependency("org.jetbrains.kotlin:kotlin-stdlib:${Versions.kotlin}", api = api) +} + +fun DependencyHandler.Compose(api: Boolean) { + val composeVersion = Versions.compose + //URL: https://developer.android.com/jetpack/androidx/releases/compose + dependency("androidx.compose.ui:ui:$composeVersion", api = api) + dependency( + "androidx.compose.foundation:foundation:${Versions.composeFoundation}", + api = api + ) + dependency( + "androidx.compose.foundation:foundation-layout:${Versions.composeFoundation}", + api = api + ) + dependency("androidx.compose.animation:animation:$composeVersion", api = api) + dependency("androidx.compose.material:material:${Versions.composeMaterial}", api = api) + dependency( + "androidx.compose.material:material-icons-extended:${Versions.composeMaterial}", api = api + ) + dependency("androidx.compose.runtime:runtime-livedata:$composeVersion", api = api) + debugDependency("androidx.compose.ui:ui-tooling:$composeVersion", api = api) + dependency("androidx.compose.ui:ui-tooling-preview:$composeVersion", api = api) + + dependency( + "androidx.navigation:navigation-compose:${Versions.navigationCompose}", api = api + ) + + dependency( + "androidx.activity:activity-compose:${Versions.composeActivity}", api = api + ) + + dependency( + "androidx.lifecycle:lifecycle-viewmodel-compose:${Versions.composeViewModel}", + api = api + ) + + Accompanist(api = api) + + Coil(api = api) + + ComposeTesting(api = api) +} + +fun DependencyHandler.Glance() { + // Jetpack Glance (Compose Widgets) + dependency("androidx.glance:glance-appwidget:${Versions.composeGlance}", api = false) +} + +/** + * Compose Window Insets + extras + * https://github.com/google/accompanist + */ +fun DependencyHandler.Accompanist(api: Boolean) { + //Set status bar color + //https://google.github.io/accompanist/systemuicontroller/ + dependency( + "com.google.accompanist:accompanist-systemuicontroller:${Versions.composeAccompanistUIController}", + api = api + ) +} + +fun DependencyHandler.Coil(api: Boolean) { + dependency("io.coil-kt:coil-compose:${Versions.composeCoil}", api = api) +} + +/** + * Required for running Compose UI tests + * https://developer.android.com/jetpack/compose/testing#setup + */ +fun DependencyHandler.ComposeTesting(api: Boolean) { + //THIS IS NOT RIGHT: Implementation for IdlingResource access on both Debug & Release + //Without having this dependency "lintRelease" fails + //TODO: Fix that + dependency("androidx.compose.ui:ui-test-junit4:${Versions.compose}", api = api) + + // Needed for createComposeRule, but not createAndroidComposeRule: + androidTestDependency( + "androidx.compose.ui:ui-test-manifest:${Versions.compose}", api = api + ) +} + +fun DependencyHandler.Google() { + //URL: https://mvnrepository.com/artifact/com.google.android.gms/play-services-auth + implementation("com.google.android.gms:play-services-auth:19.2.0") + + //URL: https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-play-services + implementation( + "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.6.3" + ) + + Billing(api = false) + + //In-App Reviews SDK + implementation("com.google.android.play:core:1.10.0") + implementation("com.google.android.play:core-ktx:1.8.1") +} + +fun DependencyHandler.Billing(api: Boolean) { + //https://developer.android.com/google/play/billing/getting-ready + dependency("com.android.billingclient:billing-ktx:${Versions.googleBilling}", api = api) +} + +fun DependencyHandler.Firebase() { + implementation("com.google.firebase:firebase-crashlytics:17.3.0") + implementation("com.google.firebase:firebase-analytics:18.0.0") + implementation("com.google.firebase:firebase-messaging:21.0.0") +} + +/** + * Hilt DI + * https://developer.android.com/training/dependency-injection/hilt-android + */ +fun DependencyHandler.Hilt() { + val api = false + dependency("com.google.dagger:hilt-android:${Versions.hilt}", api = api) + kapt("com.google.dagger:hilt-android-compiler:${Versions.hilt}") + + kapt("androidx.hilt:hilt-compiler:${Versions.hiltX}") + + //URL: https://developer.android.com/training/dependency-injection/hilt-jetpack#workmanager + dependency("androidx.hilt:hilt-work:${Versions.hiltX}", api = api) + + dependency( + "androidx.hilt:hilt-navigation-compose:${Versions.hiltNavigationCompose}", + api = api + ) + + HiltTesting() +} + +fun DependencyHandler.HiltTesting( + dependency: DependencyHandler.(String) -> Unit = { dep -> + androidTestImplementation(dep) + }, + kaptProcessor: DependencyHandler.(String) -> Unit = { dep -> + kaptAndroidTest(dep) + } +) { + dependency("com.google.dagger:hilt-android-testing:${Versions.hilt}") + kaptProcessor("com.google.dagger:hilt-android-compiler:${Versions.hilt}") +} + +/** + * https://developer.android.com/jetpack/androidx/releases/room + */ +fun DependencyHandler.RoomDB(api: Boolean) { + dependency("androidx.room:room-runtime:${Versions.room}", api = api) + kapt("androidx.room:room-compiler:${Versions.room}") + dependency("androidx.room:room-ktx:${Versions.room}", api = api) +} + +/** + * REST + */ +fun DependencyHandler.Networking(api: Boolean) { + //URL: https://github.com/square/retrofit + dependency("com.squareup.retrofit2:retrofit:${Versions.retrofit}", api = api) + dependency("com.squareup.retrofit2:converter-gson:${Versions.retrofit}", api = api) + + Gson(api = api) + + //URL: https://github.com/square/okhttp/tree/master/okhttp-logging-interceptor + dependency( + "com.squareup.okhttp3:logging-interceptor:${Versions.okhttpLogging}", api = api + ) + + Ktor(api = api) +} + +fun DependencyHandler.Ktor(api: Boolean) { + dependency("io.ktor:ktor-client-core:${Versions.ktor}", api = api) + dependency("io.ktor:ktor-client-okhttp:${Versions.ktor}", api = api) + dependency("io.ktor:ktor-client-logging:${Versions.ktor}", api = api) + dependency("io.ktor:ktor-client-content-negotiation:${Versions.ktor}", api = api) + dependency("io.ktor:ktor-serialization-gson:${Versions.ktor}", api = api) + +} + +fun DependencyHandler.Gson(api: Boolean) { + //URL: https://github.com/google/gson + dependency("com.google.code.gson:gson:${Versions.gson}", api = api) +} + +fun DependencyHandler.SerializationJson() { + //https://github.com/Kotlin/kotlinx.serialization#introduction-and-references + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:${Versions.kotlinSerialization}") +} + +/** + * Jetpack Compose Lifecycle + * https://developer.android.com/jetpack/androidx/releases/lifecycle + */ +fun DependencyHandler.Lifecycle( + api: Boolean +) { + val version = Versions.lifecycle + dependency("androidx.lifecycle:lifecycle-livedata-ktx:$version", api = api) + dependency("androidx.lifecycle:lifecycle-viewmodel-ktx:$version", api = api) + dependency("androidx.lifecycle:lifecycle-viewmodel-savedstate:$version", api = api) + dependency("androidx.lifecycle:lifecycle-runtime-ktx:$version", api = api) + + //TODO: Warning "kapt" is not transitive! + kapt("androidx.lifecycle:lifecycle-compiler:$version") +} + +fun DependencyHandler.AndroidX(api: Boolean) { + AppCompat(api) + + //URL: https://developer.android.com/jetpack/androidx/releases/core + dependency("androidx.core:core-ktx:${Versions.coreKtx}", api = api) + + //https://developer.android.com/jetpack/androidx/releases/work + dependency("androidx.work:work-runtime-ktx:${Versions.workVersion}", api = api) + dependency("androidx.work:work-testing:${Versions.workVersion}", api = api) + + dependency("androidx.biometric:biometric:${Versions.biometric}", api = api) + + //URL: https://developer.android.com/jetpack/androidx/releases/recyclerview + dependency("androidx.recyclerview:recyclerview:${Versions.recyclerView}", api = api) + + //https://developer.android.com/jetpack/androidx/releases/webkit + dependency("androidx.webkit:webkit:${Versions.webkit}", api = api) +} + +fun DependencyHandler.AppCompat(api: Boolean) { + //https://developer.android.com/jetpack/androidx/releases/appcompat + dependency("androidx.appcompat:appcompat:${Versions.appCompat}", api = api) +} + + +fun DependencyHandler.Coroutines( + api: Boolean +) { + val version = Versions.coroutines + //URL: https://github.com/Kotlin/kotlinx.coroutines + dependency("org.jetbrains.kotlinx:kotlinx-coroutines-core:$version", api = api) + dependency("org.jetbrains.kotlinx:kotlinx-coroutines-android:$version", api = api) + + //URL: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/ + androidTestDependency( + "org.jetbrains.kotlinx:kotlinx-coroutines-test:$version", api = api + ) + testDependency( + "org.jetbrains.kotlinx:kotlinx-coroutines-test:$version", api = api + ) +} + +fun DependencyHandler.ThirdParty() { + //URL: https://github.com/airbnb/lottie-android + implementation("com.airbnb.android:lottie:3.7.0") + + //URL: https://github.com/jeziellago/compose-markdown + implementation("com.github.jeziellago:compose-markdown:0.2.6") + + Keval() + EventBus() + + OpenCSV() +} + +fun DependencyHandler.Keval() { + //URL: https://github.com/notKamui/Keval - evaluate math expressions (calculator) + // TODO: Remove keval because we're using our own `:parser` + `:math` + implementation("com.notkamui.libs:keval:0.8.0") +} + +fun DependencyHandler.OpenCSV() { + implementation("com.opencsv:opencsv:5.5") + implementation("org.apache.commons:commons-lang3:3.12.0") +} + +fun DependencyHandler.EventBus() { + //URL: https://github.com/greenrobot/EventBus/releases + implementation("org.greenrobot:eventbus:${Versions.eventBus}") +} + +fun DependencyHandler.Timber(api: Boolean) { + //URL: https://github.com/JakeWharton/timber/releases + dependency("com.jakewharton.timber:timber:${Versions.timber}", api = api) +} + +fun DependencyHandler.FunctionalProgramming(api: Boolean) { + Arrow(api) +} + +fun DependencyHandler.RealmDb() { + implementation("io.realm.kotlin:library-base:${Versions.realm}") +} + +/** + * Functional Programming with Kotlin + */ +fun DependencyHandler.Arrow( + api: Boolean +) { + dependency(platform("io.arrow-kt:arrow-stack:${Versions.arrow}"), api = api) + dependency("io.arrow-kt:arrow-core", api = api) + dependency("io.arrow-kt:arrow-fx-coroutines", api = api) + dependency("io.arrow-kt:arrow-fx-stm", api = api) + + // Optics + dependency("io.arrow-kt:arrow-optics", api = api) + ksp("io.arrow-kt:arrow-optics-ksp-plugin:${Versions.arrow}") +} + +fun DependencyHandler.Testing( + commonTest: Boolean = true, + commonAndroidTest: Boolean = true +) { + Kotest() + // Robolectric doesn't integrate well with JUnit5 and Kotest +// Robolectric(api = false) + + if (commonTest) { + testImplementation(project(":common:test")) + } + if (commonAndroidTest) { + androidTestImplementation(project(":common:android-test")) + } +} + +fun DependencyHandler.AndroidXTest( + dependency: DependencyHandler.(String) -> Unit = { dep -> + androidTestImplementation(dep) + } +) { + // To use the androidx.test.core APIs + dependency("androidx.test:core:${Versions.testCore}") + // Kotlin extensions for androidx.test.core + dependency("androidx.test:core-ktx:${Versions.testCore}") + + // To use the JUnit Extension APIs + dependency("androidx.test.ext:junit:${Versions.testJunitExt}") + // Kotlin extensions for androidx.test.ext.junit + dependency("androidx.test.ext:junit-ktx:${Versions.testJunitExt}") + + // To use the androidx.test.runner APIs + dependency("androidx.test:runner:${Versions.testRunner}") +} + +/** + * Kotlin Property-based testing + */ +fun DependencyHandler.Kotest() { + val api = false //TODO: Kotest API does not work + //junit5 is required! + testDependency("org.junit.jupiter:junit-jupiter:${Versions.junitJupiter}", api = api) + testDependency("io.kotest:kotest-runner-junit5:${Versions.kotest}", api = api) + + testDependency("io.kotest:kotest-assertions-core:${Versions.kotest}", api = api) + androidTestDependency("io.kotest:kotest-assertions-core:${Versions.kotest}", api = api) + + testDependency("io.kotest:kotest-property:${Versions.kotest}", api = api) + testDependency("io.kotest:kotest-framework-datatest:${Versions.kotest}", api = api) + testDependency("io.kotest:kotest-framework-api-jvm:${Versions.kotest}", api = api) + testImplementation("io.kotest:kotest-framework-engine-jvm:${Versions.kotest}") + + //otherwise Kotest doesn't work... + testDependency("org.jetbrains.kotlin:kotlin-reflect:${Versions.kotlin}", api = api) + + + // Kotest - Arrow extensions + val kotestArrow = Versions.kotestArrow + testDependency( + "io.kotest.extensions:kotest-assertions-arrow:$kotestArrow", api = api + ) + testDependency( + "io.kotest.extensions:kotest-assertions-arrow-fx-coroutines:$kotestArrow", + api = api + ) + testDependency( + "io.kotest.extensions:kotest-property-arrow:$kotestArrow", api = api + ) +} + +fun DependencyHandler.Robolectric(api: Boolean) { + testDependency("org.robolectric:robolectric:${Versions.robolectric}", api = api) + testDependency("junit:junit:${Versions.robolectricJunit}", api = api) + testDependency( + "io.kotest.extensions:kotest-extensions-robolectric:${Versions.robolectricKotestExt}", + api = api, + ) +} \ No newline at end of file diff --git a/categories/.gitignore b/categories/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/categories/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/categories/README.md b/categories/README.md new file mode 100644 index 0000000..bd94195 --- /dev/null +++ b/categories/README.md @@ -0,0 +1,7 @@ +# Categories + +Implements the "Categories" screen where you can: +- CRUD categories. +- Set parent categories. +- See categories and their Income/Expense grouped by parent. +- Reorder categories and parent categories. \ No newline at end of file diff --git a/categories/build.gradle.kts b/categories/build.gradle.kts new file mode 100644 index 0000000..e2a355b --- /dev/null +++ b/categories/build.gradle.kts @@ -0,0 +1,19 @@ +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":design-system")) + implementation(project(":core:domain")) + implementation(project(":core:ui")) + implementation(project(":core:data-model")) + implementation(project(":navigation")) + Testing() +} \ No newline at end of file diff --git a/categories/src/main/AndroidManifest.xml b/categories/src/main/AndroidManifest.xml new file mode 100644 index 0000000..83f7349 --- /dev/null +++ b/categories/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/categories/src/main/java/com/ivy/categories/CategoriesEvent.kt b/categories/src/main/java/com/ivy/categories/CategoriesEvent.kt new file mode 100644 index 0000000..8bb86ea --- /dev/null +++ b/categories/src/main/java/com/ivy/categories/CategoriesEvent.kt @@ -0,0 +1,7 @@ +package com.ivy.categories + +import com.ivy.core.ui.data.CategoryUi + +sealed interface CategoriesEvent { + data class CategoryClick(val category: CategoryUi) : CategoriesEvent +} \ No newline at end of file diff --git a/categories/src/main/java/com/ivy/categories/CategoriesScreen.kt b/categories/src/main/java/com/ivy/categories/CategoriesScreen.kt new file mode 100644 index 0000000..d4cef20 --- /dev/null +++ b/categories/src/main/java/com/ivy/categories/CategoriesScreen.kt @@ -0,0 +1,219 @@ +package com.ivy.categories + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ivy.categories.component.categoriesList +import com.ivy.categories.data.CategoryListItemUi +import com.ivy.categories.data.CategoryListItemUi.ParentCategory +import com.ivy.core.domain.pure.format.dummyValueUi +import com.ivy.core.ui.category.create.CreateCategoryModal +import com.ivy.core.ui.category.edit.EditCategoryModal +import com.ivy.core.ui.category.reorder.ReorderCategoriesModal +import com.ivy.core.ui.component.ScreenBottomBar +import com.ivy.core.ui.data.dummyCategoryUi +import com.ivy.core.ui.data.period.SelectedPeriodUi +import com.ivy.core.ui.data.period.dummyRangeUi +import com.ivy.core.ui.time.PeriodButton +import com.ivy.core.ui.time.PeriodModal +import com.ivy.design.l0_system.color.Blue +import com.ivy.design.l0_system.color.Green +import com.ivy.design.l0_system.color.Purple2Dark +import com.ivy.design.l0_system.color.Red +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l1_buildingBlocks.SpacerWeight +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.ReorderButton +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview +import com.ivy.resources.R + +@Composable +fun BoxScope.CategoriesScreen() { + val viewModel: CategoriesViewModel = hiltViewModel() + val state by viewModel.uiState.collectAsState() + + UI(state = state, onEvent = viewModel::onEvent) +} + +@Composable +private fun BoxScope.UI( + state: CategoriesState, + onEvent: (CategoriesEvent) -> Unit, +) { + val periodModal = rememberIvyModal() + val createCategoryModal = rememberIvyModal() + var editCategoryId by remember { mutableStateOf(null) } + val editCategoryModal = rememberIvyModal() + val reorderModal = rememberIvyModal() + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding(), + ) { + item(key = "header") { + SpacerVer(height = 16.dp) + Header( + selectedPeriodUi = state.selectedPeriod, + periodModal = periodModal, + onReorder = { + reorderModal.show() + } + ) + SpacerVer(height = 20.dp) + } + categoriesList( + items = state.items, + emptyState = state.emptyState, + onCategoryClick = { + editCategoryId = it.id + editCategoryModal.show() + }, + onParentCategoryClick = { + editCategoryId = it.id + editCategoryModal.show() + }, + onCreateCategory = { + createCategoryModal.show() + } + ) + item(key = "last_item_spacer") { + SpacerVer(height = 64.dp) + } + } + + ScreenBottomBar { + IvyButton( + size = ButtonSize.Small, + visibility = Visibility.High, + feeling = Feeling.Positive, + text = "Add", + icon = R.drawable.ic_round_add_24 + ) { + createCategoryModal.show() + } + } + + PeriodModal(modal = periodModal) + + CreateCategoryModal(modal = createCategoryModal) + editCategoryId?.let { + EditCategoryModal(modal = editCategoryModal, categoryId = it) + } + ReorderCategoriesModal(modal = reorderModal) +} + +@Composable +private fun Header( + selectedPeriodUi: SelectedPeriodUi?, + periodModal: IvyModal, + onReorder: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // TODO: Refactor, selectedPeriodUi is no longer needed! + if (selectedPeriodUi != null) { + PeriodButton(periodModal = periodModal) + } + SpacerWeight(weight = 1f) + ReorderButton(onClick = onReorder) + } +} + + +// region Preview +@Preview +@Composable +private fun Preview_Empty() { + IvyPreview { + UI( + state = CategoriesState( + selectedPeriod = SelectedPeriodUi.AllTime( + periodBtnText = "All-time", + rangeUi = dummyRangeUi() + ), + items = emptyList(), + emptyState = true + ), + onEvent = {} + ) + } +} + +@Preview +@Composable +private fun Preview() { + IvyPreview { + UI( + state = CategoriesState( + selectedPeriod = SelectedPeriodUi.AllTime( + periodBtnText = "Sep", + rangeUi = dummyRangeUi() + ), + items = listOf( + ParentCategory( + parentCategory = dummyCategoryUi("Business"), + balance = dummyValueUi("+3,320.50"), + categoryCards = listOf( + CategoryListItemUi.CategoryCard( + category = dummyCategoryUi("Category 1"), + balance = dummyValueUi("-1,000.00"), + ), + CategoryListItemUi.CategoryCard( + category = dummyCategoryUi("Category 2", color = Blue), + balance = dummyValueUi("0.00"), + ), + CategoryListItemUi.CategoryCard( + category = dummyCategoryUi("Category 3", color = Red), + balance = dummyValueUi("+4,320.50"), + ), + ), + categoriesCount = 3, + ), + CategoryListItemUi.CategoryCard( + category = dummyCategoryUi("Tech", color = Purple2Dark), + balance = dummyValueUi("-30k"), + ), + CategoryListItemUi.CategoryCard( + category = dummyCategoryUi("Groceries", color = Green), + balance = dummyValueUi("-5,025.54"), + ), + CategoryListItemUi.Archived( + categoryCards = listOf( + CategoryListItemUi.CategoryCard( + category = dummyCategoryUi("Category 1"), + balance = dummyValueUi("-1,000.00", "BGN"), + ), + CategoryListItemUi.CategoryCard( + category = dummyCategoryUi("Category 2", color = Blue), + balance = dummyValueUi("0.00"), + ), + CategoryListItemUi.CategoryCard( + category = dummyCategoryUi("Account 3", color = Red), + balance = dummyValueUi("+4,320.50"), + ), + ), + count = 3, + ) + ), + emptyState = false, + ), + onEvent = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/categories/src/main/java/com/ivy/categories/CategoriesState.kt b/categories/src/main/java/com/ivy/categories/CategoriesState.kt new file mode 100644 index 0000000..fce34ac --- /dev/null +++ b/categories/src/main/java/com/ivy/categories/CategoriesState.kt @@ -0,0 +1,12 @@ +package com.ivy.categories + +import androidx.compose.runtime.Immutable +import com.ivy.categories.data.CategoryListItemUi +import com.ivy.core.ui.data.period.SelectedPeriodUi + +@Immutable +data class CategoriesState( + val selectedPeriod: SelectedPeriodUi?, + val items: List, + val emptyState: Boolean, +) \ No newline at end of file diff --git a/categories/src/main/java/com/ivy/categories/CategoriesViewModel.kt b/categories/src/main/java/com/ivy/categories/CategoriesViewModel.kt new file mode 100644 index 0000000..a9c3d56 --- /dev/null +++ b/categories/src/main/java/com/ivy/categories/CategoriesViewModel.kt @@ -0,0 +1,135 @@ +package com.ivy.categories + +import com.ivy.categories.data.CategoryListItemUi +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.action.calculate.Stats +import com.ivy.core.domain.action.calculate.category.CatStatsFlow +import com.ivy.core.domain.action.category.CategoriesListFlow +import com.ivy.core.domain.action.data.CategoryListItem +import com.ivy.core.domain.action.period.SelectedPeriodFlow +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.format +import com.ivy.core.domain.pure.util.combineList +import com.ivy.core.ui.action.mapping.MapCategoryUiAct +import com.ivy.core.ui.action.mapping.MapSelectedPeriodUiAct +import com.ivy.data.Value +import com.ivy.data.category.Category +import com.ivy.data.time.TimeRange +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +@HiltViewModel +class CategoriesViewModel @Inject constructor( + private val categoriesListFlow: CategoriesListFlow, + private val selectedPeriodFlow: SelectedPeriodFlow, + private val mapSelectedPeriodUiAct: MapSelectedPeriodUiAct, + private val categoryStatsFlow: CatStatsFlow, + private val mapCategoryUiAct: MapCategoryUiAct, +) : SimpleFlowViewModel() { + override val initialUi = CategoriesState( + selectedPeriod = null, + items = listOf(), + emptyState = true, + ) + + override val uiFlow: Flow = combine( + selectedPeriodFlow(), categoryItemsUi() + ) { period, items -> + CategoriesState( + selectedPeriod = mapSelectedPeriodUiAct(period), + items = items, + emptyState = items.isEmpty(), + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun categoryItemsUi(): Flow> = combine( + selectedPeriodFlow(), + categoriesListFlow(CategoriesListFlow.Input(trnType = null)) + ) { period, list -> + val range = period.range + combineList(list.map { item -> + when (item) { + is CategoryListItem.Archived -> { + combineList( + item.categories.map { category -> + categoryCardFlow(category, range) + } + ).map { cards -> + CategoryListItemUi.Archived( + categoryCards = cards, + count = cards.size + ) + } + } + is CategoryListItem.CategoryHolder -> categoryCardFlow(item.category, range) + is CategoryListItem.ParentCategory -> { + combine( + categoryStatsFlow( + CatStatsFlow.Input( + category = item.parent, + range = range, + ) + ), + combineList(item.children.map { category -> + categoryStatsFlow( + CatStatsFlow.Input( + category = category, + range = range, + ) + ).map { stats -> + CategoryListItemUi.CategoryCard( + category = mapCategoryUiAct(category), + balance = format(stats.balance, shortenFiat = true), + ) to stats + } + }) + ) { parentStats, children -> + CategoryListItemUi.ParentCategory( + parentCategory = mapCategoryUiAct(item.parent), + balance = parentCategoryBalance( + parentStats, + children.map { it.second } + ), + categoryCards = children.map { it.first }, + categoriesCount = children.size + ) + } + } + + } + }).map { it } + }.flatMapLatest { it } + + private fun parentCategoryBalance(parentStats: Stats, children: List): ValueUi { + val totalBalance = parentStats.balance.amount + children.sumOf { it.balance.amount } + return format(Value(totalBalance, parentStats.balance.currency), shortenFiat = true) + } + + private fun categoryCardFlow( + category: Category, range: TimeRange + ): Flow = categoryStatsFlow( + CatStatsFlow.Input( + category = category, + range = range + ) + ).map { stats -> + CategoryListItemUi.CategoryCard( + category = mapCategoryUiAct(category), + balance = format(stats.balance, shortenFiat = true), + ) + } + + + // region Event handling + override suspend fun handleEvent(event: CategoriesEvent) = when (event) { + is CategoriesEvent.CategoryClick -> handleCategoryClick(event) + } + + private fun handleCategoryClick(event: CategoriesEvent.CategoryClick) { + // TODO: Implement + } + // endregion +} diff --git a/categories/src/main/java/com/ivy/categories/component/ArchivedCategories.kt b/categories/src/main/java/com/ivy/categories/component/ArchivedCategories.kt new file mode 100644 index 0000000..78feb01 --- /dev/null +++ b/categories/src/main/java/com/ivy/categories/component/ArchivedCategories.kt @@ -0,0 +1,122 @@ +package com.ivy.categories.component + +import androidx.compose.animation.* +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.* +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.categories.R +import com.ivy.categories.data.CategoryListItemUi +import com.ivy.categories.data.CategoryListItemUi.CategoryCard +import com.ivy.core.domain.pure.format.dummyValueUi +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.dummyCategoryUi +import com.ivy.design.l0_system.color.Blue +import com.ivy.design.l0_system.color.Red +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.isInPreview + +@Composable +internal fun ArchivedCategories( + archived: CategoryListItemUi.Archived, + onCategoryClick: (CategoryUi) -> Unit, +) { + var expanded by if (isInPreview()) remember { + mutableStateOf(previewExpanded) + } else remember { mutableStateOf(false) } + ArchivedDivider( + expanded = expanded, + count = archived.count, + onSetExpanded = { expanded = it } + ) + AccountsList( + categories = archived.categoryCards, + expanded = expanded, + onCategoryClick = onCategoryClick + ) +} + +@Composable +private fun ArchivedDivider( + expanded: Boolean, + count: Int, + onSetExpanded: (Boolean) -> Unit +) { + IvyButton( + size = ButtonSize.Big, + visibility = Visibility.Low, + feeling = Feeling.Neutral, + text = "Archived ($count)", + icon = if (expanded) + R.drawable.round_expand_more_24 else R.drawable.ic_round_expand_less_24 + ) { + onSetExpanded(!expanded) + } +} + +@Composable +private fun AccountsList( + categories: List, + expanded: Boolean, + onCategoryClick: (CategoryUi) -> Unit, +) { + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column { + categories.forEach { item -> + key("archived_${item.category.id}") { + SpacerVer(height = 12.dp) + CategoryCard( + category = item.category, + balance = item.balance, + ) { + onCategoryClick(item.category) + } + } + } + } + } +} + + +// region Preview +private var previewExpanded = false + +@Preview +@Composable +private fun Preview() { + ComponentPreview { + previewExpanded = true + Column { + ArchivedCategories( + archived = CategoryListItemUi.Archived( + categoryCards = listOf( + CategoryCard( + category = dummyCategoryUi("Category 1"), + balance = dummyValueUi("-1,000.00", "BGN"), + ), + CategoryCard( + category = dummyCategoryUi("Category 2", color = Blue), + balance = dummyValueUi("0.00"), + ), + CategoryCard( + category = dummyCategoryUi("Category 3", color = Red), + balance = dummyValueUi("+4,320.50"), + ), + ), + count = 3, + ), + onCategoryClick = {} + ) + } + } +} +// endregion \ No newline at end of file diff --git a/categories/src/main/java/com/ivy/categories/component/CategoriesList.kt b/categories/src/main/java/com/ivy/categories/component/CategoriesList.kt new file mode 100644 index 0000000..1918601 --- /dev/null +++ b/categories/src/main/java/com/ivy/categories/component/CategoriesList.kt @@ -0,0 +1,131 @@ +package com.ivy.categories.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +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.ivy.categories.R +import com.ivy.categories.data.CategoryListItemUi +import com.ivy.categories.data.CategoryListItemUi.* +import com.ivy.core.ui.data.CategoryUi +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.B1 +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview + +fun LazyListScope.categoriesList( + items: List, + emptyState: Boolean, + onCategoryClick: (CategoryUi) -> Unit, + onParentCategoryClick: (CategoryUi) -> Unit, + onCreateCategory: () -> Unit, +) { + items( + items = items, + key = { + when (it) { + is Archived -> "archived" + is CategoryCard -> it.category.id + is ParentCategory -> it.parentCategory.id + } + } + ) { item -> + when (item) { + is CategoryCard -> { + SpacerVer(height = 8.dp) + CategoryCard( + category = item.category, + balance = item.balance, + onClick = { onCategoryClick(item.category) } + ) + } + is ParentCategory -> { + SpacerVer(height = 8.dp) + CategoryFolderCard( + parentCategory = item.parentCategory, + balance = item.balance, + categories = item.categoryCards, + categoriesCount = item.categoriesCount, + onCategoryClick = onCategoryClick, + onParentCategoryClick = { + onParentCategoryClick(item.parentCategory) + }, + ) + } + is Archived -> { + SpacerVer(height = 16.dp) + ArchivedCategories(archived = item, onCategoryClick = onCategoryClick) + } + } + } + + if (emptyState) { + item { + EmptyState(onCreateCategory = onCreateCategory) + } + } +} + +@Composable +private fun EmptyState( + onCreateCategory: () -> Unit +) { + SpacerVer(height = 96.dp) + B1( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = "No Categories", + textAlign = TextAlign.Center, + color = UI.colors.primary + ) + SpacerVer(height = 12.dp) + B2( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = "Create categories to better organize you transactions", + textAlign = TextAlign.Center + ) + SpacerVer(height = 12.dp) + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Focused, + feeling = Feeling.Positive, + text = "Create Category", + icon = R.drawable.ic_custom_category_s, + onClick = onCreateCategory, + ) + SpacerVer(height = 24.dp) +} + + +// region Preview +@Preview +@Composable +private fun Preview_EmptyState() { + ComponentPreview { + LazyColumn { + categoriesList( + items = emptyList(), + emptyState = false, + onCategoryClick = {}, + onCreateCategory = {}, + onParentCategoryClick = {}, + ) + } + } +} +// endregion \ No newline at end of file diff --git a/categories/src/main/java/com/ivy/categories/component/CategoryCard.kt b/categories/src/main/java/com/ivy/categories/component/CategoryCard.kt new file mode 100644 index 0000000..5fb4765 --- /dev/null +++ b/categories/src/main/java/com/ivy/categories/component/CategoryCard.kt @@ -0,0 +1,107 @@ +package com.ivy.categories.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.dummyValueUi +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.dummyCategoryUi +import com.ivy.core.ui.data.icon.IconSize +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.core.ui.icon.ItemIcon +import com.ivy.core.ui.value.AmountCurrency +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.rememberContrast +import com.ivy.design.l0_system.color.rememberDynamicContrast +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.util.ComponentPreview + +@Composable +internal fun CategoryCard( + category: CategoryUi, + balance: ValueUi, + onClick: () -> Unit, +) { + val dynamicContrast = rememberDynamicContrast(category.color) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .clip(UI.shapes.rounded) + .border(1.dp, dynamicContrast, UI.shapes.rounded) + .clickable(onClick = onClick) + ) { + val contrast = rememberContrast(category.color) + Header( + icon = category.icon, + name = category.name, + color = category.color, + contrast = contrast, + ) + Balance(balance = balance) + } +} + +@Composable +private fun Header( + icon: ItemIcon, + name: String, + color: Color, + contrast: Color, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(color, UI.shapes.roundedTop) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ItemIcon(itemIcon = icon, size = IconSize.M, tint = contrast) + B2( + modifier = Modifier + .weight(1f) + .padding(start = 4.dp), + text = name, + color = contrast, + ) + } +} + +@Composable +private fun Balance( + balance: ValueUi +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + AmountCurrency(value = balance) + } +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + ComponentPreview { + CategoryCard( + category = dummyCategoryUi("Category"), + balance = dummyValueUi("-185.00"), + onClick = {}, + ) + } +} +// endregion \ No newline at end of file diff --git a/categories/src/main/java/com/ivy/categories/component/CategoryFolderCard.kt b/categories/src/main/java/com/ivy/categories/component/CategoryFolderCard.kt new file mode 100644 index 0000000..7f10d86 --- /dev/null +++ b/categories/src/main/java/com/ivy/categories/component/CategoryFolderCard.kt @@ -0,0 +1,244 @@ +package com.ivy.categories.component + +import androidx.compose.animation.* +import androidx.compose.foundation.background +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.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.categories.R +import com.ivy.categories.data.CategoryListItemUi.CategoryCard +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.dummyValueUi +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.dummyCategoryUi +import com.ivy.core.ui.data.icon.IconSize +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.core.ui.icon.ItemIcon +import com.ivy.core.ui.value.AmountCurrency +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.Blue +import com.ivy.design.l0_system.color.Red +import com.ivy.design.l0_system.color.rememberContrast +import com.ivy.design.l0_system.color.rememberDynamicContrast +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.isInPreview + + +@Composable +fun CategoryFolderCard( + parentCategory: CategoryUi, + balance: ValueUi, + categories: List, + categoriesCount: Int, + modifier: Modifier = Modifier, + onCategoryClick: (CategoryUi) -> Unit, + onParentCategoryClick: () -> Unit, +) { + val dynamicContrast = rememberDynamicContrast(parentCategory.color) + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .clip(UI.shapes.rounded) + .border(1.dp, dynamicContrast, UI.shapes.rounded) + .clickable(onClick = onParentCategoryClick), + ) { + val contrastColor = rememberContrast(parentCategory.color) + Column( + modifier = Modifier + .fillMaxWidth() + .background(parentCategory.color, UI.shapes.roundedTop) + .padding(horizontal = 16.dp) + .padding(top = 4.dp, bottom = 12.dp) + ) { + IconNameRow( + folderName = parentCategory.name, + folderIcon = parentCategory.icon, + color = contrastColor + ) + SpacerVer(height = 2.dp) + Balance(balance = balance, color = contrastColor) + } + var expanded by if (isInPreview()) remember { + mutableStateOf(previewExpanded) + } else remember { mutableStateOf(false) } + ExpandCollapse( + expanded = expanded, + color = UI.colorsInverted.pure, + count = categoriesCount, + onSetExpanded = { expanded = it } + ) + Categories(expanded = expanded, items = categories, onClick = onCategoryClick) + } +} + +@Composable +private fun IconNameRow( + folderName: String, + folderIcon: ItemIcon, + color: Color, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + ItemIcon( + itemIcon = folderIcon, + size = IconSize.M, + tint = color, + ) + SpacerHor(width = 8.dp) + B2( + modifier = Modifier.weight(1f), + text = folderName, + color = color, + fontWeight = FontWeight.ExtraBold + ) + } +} + +@Composable +private fun Balance( + balance: ValueUi, + color: Color, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(start = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AmountCurrency(value = balance, color = color) + } +} + +@Composable +private fun ExpandCollapse( + expanded: Boolean, + color: Color, + count: Int, + onSetExpanded: (Boolean) -> Unit +) { + if (count > 0) { + IvyButton( + size = ButtonSize.Big, + shape = UI.shapes.roundedBottom, + visibility = Visibility.Low, + feeling = Feeling.Custom(color), + text = if (expanded) + "Tap to collapse ($count)" else "Tap to expand ($count)", + icon = if (expanded) + R.drawable.ic_round_expand_less_24 else R.drawable.round_expand_more_24 + ) { + onSetExpanded(!expanded) + } + } else { + B2( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + text = "Empty folder", + fontWeight = FontWeight.ExtraBold, + color = UI.colors.neutral, + textAlign = TextAlign.Center + ) + } + +} + +@Composable +private fun Categories( + expanded: Boolean, + items: List, + onClick: (CategoryUi) -> Unit +) { + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column(Modifier.fillMaxWidth()) { + items.forEach { + key(it.category.id) { + CategoryCard( + category = it.category, + balance = it.balance, + onClick = { onClick(it.category) } + ) + SpacerVer(height = 8.dp) + } + } + SpacerVer(height = 4.dp) + } + } +} + + +// region Preview +private var previewExpanded = false + +@Preview +@Composable +private fun Preview_Collapsed() { + ComponentPreview { + CategoryFolderCard( + parentCategory = dummyCategoryUi("Business"), + balance = dummyValueUi("5,320.50"), + categories = emptyList(), + categoriesCount = 0, + onCategoryClick = {}, + onParentCategoryClick = {}, + ) + } +} + +@Preview +@Composable +private fun Preview_Expanded() { + ComponentPreview { + previewExpanded = true + CategoryFolderCard( + parentCategory = dummyCategoryUi("Business"), + balance = dummyValueUi("+3,320.50"), + categories = listOf( + CategoryCard( + category = dummyCategoryUi("Category 1"), + balance = dummyValueUi("-1,000.00"), + ), + CategoryCard( + category = dummyCategoryUi("Category 2", color = Blue), + balance = dummyValueUi("0.00"), + ), + CategoryCard( + category = dummyCategoryUi("Category 3", color = Red), + balance = dummyValueUi("+4,320.50"), + ), + ), + categoriesCount = 3, + onCategoryClick = {}, + onParentCategoryClick = {}, + ) + } +} +// endregion \ No newline at end of file diff --git a/categories/src/main/java/com/ivy/categories/data/CategoryListItemUi.kt b/categories/src/main/java/com/ivy/categories/data/CategoryListItemUi.kt new file mode 100644 index 0000000..914416d --- /dev/null +++ b/categories/src/main/java/com/ivy/categories/data/CategoryListItemUi.kt @@ -0,0 +1,28 @@ +package com.ivy.categories.data + +import androidx.compose.runtime.Immutable +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.ui.data.CategoryUi + +@Immutable +sealed interface CategoryListItemUi { + @Immutable + data class CategoryCard( + val category: CategoryUi, + val balance: ValueUi, + ) : CategoryListItemUi + + @Immutable + data class ParentCategory( + val parentCategory: CategoryUi, + val balance: ValueUi, + val categoryCards: List, + val categoriesCount: Int, + ) : CategoryListItemUi + + @Immutable + data class Archived( + val categoryCards: List, + val count: Int, + ) : CategoryListItemUi +} \ No newline at end of file diff --git a/check_for_updates.sh b/check_for_updates.sh new file mode 100755 index 0000000..13b92ed --- /dev/null +++ b/check_for_updates.sh @@ -0,0 +1,3 @@ +./gradlew dependencyUpdates -DoutputFormatter=html,json,xml || exit +echo "Opening results in Chrome:" +google-chrome build/reports/dependencyUpdates/report.html diff --git a/checkout_pr.sh b/checkout_pr.sh new file mode 100755 index 0000000..3749a31 --- /dev/null +++ b/checkout_pr.sh @@ -0,0 +1,5 @@ +PR_ID=$1 +BRANCH_NAME=$2 + +git fetch origin pull/$PR_ID/head:$BRANCH_NAME +git checkout $BRANCH_NAME diff --git a/common/README.md b/common/README.md new file mode 100644 index 0000000..28f78b6 --- /dev/null +++ b/common/README.md @@ -0,0 +1,6 @@ +# Common + +Common dependencies and useful code intended to be used in all modules. +- `:common:android-test` - deps for integration tests. +- `:common:main` - Ivy Wallet's main dependencies, feel free to add it to your module. +- `:common:test` - deps for unit testing. \ No newline at end of file diff --git a/common/android-test/.gitignore b/common/android-test/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/common/android-test/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/common/android-test/README.md b/common/android-test/README.md new file mode 100644 index 0000000..ef69424 --- /dev/null +++ b/common/android-test/README.md @@ -0,0 +1,3 @@ +# Common: androidTest + +Common dependencies for the `androidTest` source set responsible for android's instrumentation/integration tests. \ No newline at end of file diff --git a/common/android-test/build.gradle.kts b/common/android-test/build.gradle.kts new file mode 100644 index 0000000..a849ef6 --- /dev/null +++ b/common/android-test/build.gradle.kts @@ -0,0 +1,30 @@ +import com.ivy.buildsrc.* + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + HiltTesting( + dependency = { api(it) }, + kaptProcessor = { kapt(it) } + ) + + Kotlin(api = false) + Coroutines(api = false) + AndroidXTest(dependency = { api(it) }) + + + Testing( + // :common:test needs to be added as implementation dep + // else won't work + commonTest = false, + // Prevent circular dependency + commonAndroidTest = false + ) + api(project(":common:test")) // expose :common:test classes to all androidTest +} \ No newline at end of file diff --git a/common/android-test/src/main/AndroidManifest.xml b/common/android-test/src/main/AndroidManifest.xml new file mode 100644 index 0000000..dbd0c8e --- /dev/null +++ b/common/android-test/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/common/android-test/src/main/java/com/ivy/common/androidtest/AndroidTest.kt b/common/android-test/src/main/java/com/ivy/common/androidtest/AndroidTest.kt new file mode 100644 index 0000000..248ce1d --- /dev/null +++ b/common/android-test/src/main/java/com/ivy/common/androidtest/AndroidTest.kt @@ -0,0 +1,7 @@ +package com.ivy.common.androidtest + +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4ClassRunner::class) +annotation class AndroidTest diff --git a/common/android-test/src/main/java/com/ivy/common/androidtest/AndroidTestExt.kt b/common/android-test/src/main/java/com/ivy/common/androidtest/AndroidTestExt.kt new file mode 100644 index 0000000..63ce1ba --- /dev/null +++ b/common/android-test/src/main/java/com/ivy/common/androidtest/AndroidTestExt.kt @@ -0,0 +1,6 @@ +package com.ivy.common.androidtest + +import android.content.Context +import androidx.test.core.app.ApplicationProvider + +fun testContext(): Context = ApplicationProvider.getApplicationContext() \ No newline at end of file diff --git a/common/android-test/src/main/java/com/ivy/common/androidtest/Common.kt b/common/android-test/src/main/java/com/ivy/common/androidtest/Common.kt new file mode 100644 index 0000000..5612d16 --- /dev/null +++ b/common/android-test/src/main/java/com/ivy/common/androidtest/Common.kt @@ -0,0 +1,9 @@ +package com.ivy.common.androidtest + +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.* + +fun uuidString(): String = UUID.randomUUID().toString() + +fun Instant.epochSeconds(): Instant = this.truncatedTo(ChronoUnit.SECONDS) \ No newline at end of file diff --git a/common/android-test/src/main/java/com/ivy/common/androidtest/IvyTestRunner.kt b/common/android-test/src/main/java/com/ivy/common/androidtest/IvyTestRunner.kt new file mode 100644 index 0000000..8db810e --- /dev/null +++ b/common/android-test/src/main/java/com/ivy/common/androidtest/IvyTestRunner.kt @@ -0,0 +1,13 @@ +package com.ivy.common.androidtest + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +@Suppress("UNUSED") +class IvyTestRunner : AndroidJUnitRunner() { + override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } +} \ No newline at end of file diff --git a/common/main/.gitignore b/common/main/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/common/main/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/common/main/README.md b/common/main/README.md new file mode 100644 index 0000000..a22411c --- /dev/null +++ b/common/main/README.md @@ -0,0 +1,10 @@ +# Common: main + +Common dependencies for the `main` sourceset that can be helpful for all modules. + +**Includes:** +- time logic +- helpful utils +- exposes the `:core:data-model` +- exposes `:resources` +- exposes common external deps \ No newline at end of file diff --git a/common/main/build.gradle.kts b/common/main/build.gradle.kts new file mode 100644 index 0000000..15da5ab --- /dev/null +++ b/common/main/build.gradle.kts @@ -0,0 +1,25 @@ +import com.ivy.buildsrc.* + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + api(project(":core:data-model")) + api(project(":resources")) + + Hilt() + Kotlin(api = true) + Coroutines(api = true) + FunctionalProgramming(api = true) + Timber(api = true) + + Testing( + // Prevent circular dependency + commonTest = false, + commonAndroidTest = false + ) +} \ No newline at end of file diff --git a/common/main/src/main/AndroidManifest.xml b/common/main/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f65d137 --- /dev/null +++ b/common/main/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/common/main/src/main/java/com/ivy/common/CommonExt.kt b/common/main/src/main/java/com/ivy/common/CommonExt.kt new file mode 100644 index 0000000..a815da6 --- /dev/null +++ b/common/main/src/main/java/com/ivy/common/CommonExt.kt @@ -0,0 +1,15 @@ +package com.ivy.common + +import java.util.* + +fun String.toUUID(): UUID = UUID.fromString(this) + +fun String.toUUIDOrNull(): UUID? = try { + UUID.fromString(this) +} catch (e: Exception) { + null +} + +fun String?.isNotEmpty(): Boolean = !isNullOrEmpty() + +fun String?.isNotBlank(): Boolean = !isNullOrBlank() \ No newline at end of file diff --git a/common/main/src/main/java/com/ivy/common/Constants.kt b/common/main/src/main/java/com/ivy/common/Constants.kt new file mode 100644 index 0000000..dd1c74f --- /dev/null +++ b/common/main/src/main/java/com/ivy/common/Constants.kt @@ -0,0 +1,46 @@ +package com.ivy.common + +object Constants { + const val ENABLE_PAYWALL_ON_DEBUG = false + const val PREMIUM_INITIAL_VALUE_DEBUG = true + + const val FREE_ACCOUNTS = 3 + const val FREE_CATEGORIES = 12 + const val FREE_BUDGETS = 2 + const val FREE_LOANS = 2 + + const val URL_TC = + "https://github.com/ILIYANGERMANOV/privacy-policies/blob/master/ivy-wallet-tc.md" + const val URL_PRIVACY_POLICY = + "https://github.com/ILIYANGERMANOV/privacy-policies/blob/master/ivy-wallet-privacy-policy.md" + + const val URL_IVY_WALLET_REPO = "https://github.com/Ivy-Apps/ivy-wallet" + + const val URL_ROADMAP = "https://github.com/Ivy-Apps/ivy-wallet/projects/1" + + const val URL_HELP_CENTER = "https://github.com/Ivy-Apps/ivy-wallet/wiki" + + const val URL_IVY_TELEGRAM_INVITE = "https://t.me/+ETavgioAvWg4NThk" + + const val URL_IVY_WALLET_GOOGLE_PLAY = + "https://play.google.com/store/apps/details?id=com.ivy.wallet" + + const val CATEGORY_UNSPECIFIED_NAME = "Unspecified" + + const val URL_IVY_CONTRIBUTORS = + "https://github.com/Ivy-Apps/ivy-wallet#contributors-see-graph" + + const val USER_INACTIVITY_TIME_LIMIT = 60 //Time in seconds + + const val SWIPE_DOWN_THRESHOLD_OPEN_MORE_MENU = 200 + + const val SWIPE_UP_EXPANDED_THRESHOLD = 200 + + const val SUPPORT_EMAIL = "iliyan.germanov971@gmail.com" + + const val PAGE_TRANSACTIONS_SIZE = 100 + + const val IVY_WALLET_APP_ID = "com.ivy.wallet" + + const val SUGGESTIONS_LIMIT = 10 +} \ No newline at end of file diff --git a/common/main/src/main/java/com/ivy/common/NonEmptyListExt.kt b/common/main/src/main/java/com/ivy/common/NonEmptyListExt.kt new file mode 100644 index 0000000..4161d25 --- /dev/null +++ b/common/main/src/main/java/com/ivy/common/NonEmptyListExt.kt @@ -0,0 +1,6 @@ +package com.ivy.common + +import arrow.core.NonEmptyList + +fun List.toNonEmptyList(): NonEmptyList = NonEmptyList.fromListUnsafe(this) +fun Set.toNonEmptyList(): NonEmptyList = NonEmptyList.fromListUnsafe(this.toList()) \ No newline at end of file diff --git a/common/main/src/main/java/com/ivy/common/Quadruple.kt b/common/main/src/main/java/com/ivy/common/Quadruple.kt new file mode 100644 index 0000000..da15381 --- /dev/null +++ b/common/main/src/main/java/com/ivy/common/Quadruple.kt @@ -0,0 +1,12 @@ +package com.ivy.common + +import java.io.Serializable + +data class Quadruple( + val first: A, + val second: B, + val third: C, + val fourth: D +) : Serializable { + override fun toString(): String = "($first, $second, $third, $fourth)" +} \ No newline at end of file diff --git a/common/main/src/main/java/com/ivy/common/StringUtil.kt b/common/main/src/main/java/com/ivy/common/StringUtil.kt new file mode 100644 index 0000000..0bdf486 --- /dev/null +++ b/common/main/src/main/java/com/ivy/common/StringUtil.kt @@ -0,0 +1,3 @@ +package com.ivy.common + +fun String?.isNotNullOrBlank(): Boolean = !isNullOrBlank() \ No newline at end of file diff --git a/common/main/src/main/java/com/ivy/common/di/CommonModuleDI.kt b/common/main/src/main/java/com/ivy/common/di/CommonModuleDI.kt new file mode 100644 index 0000000..09490ef --- /dev/null +++ b/common/main/src/main/java/com/ivy/common/di/CommonModuleDI.kt @@ -0,0 +1,17 @@ +package com.ivy.common.di + +import com.ivy.common.time.provider.DeviceTimeProvider +import com.ivy.common.time.provider.TimeProvider +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class CommonModuleDI { + @Singleton + @Binds + abstract fun timeProvider(provider: DeviceTimeProvider): TimeProvider +} \ No newline at end of file diff --git a/common/main/src/main/java/com/ivy/common/time/CalendarPeriod.kt b/common/main/src/main/java/com/ivy/common/time/CalendarPeriod.kt new file mode 100644 index 0000000..f1a9f01 --- /dev/null +++ b/common/main/src/main/java/com/ivy/common/time/CalendarPeriod.kt @@ -0,0 +1,44 @@ +package com.ivy.common.time + +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.temporal.TemporalAdjusters + +// region Day +// .atStartOfDay() is already built-in in LocalDate + +fun LocalDate.atEndOfDay(): LocalDateTime = + this.atTime(23, 59, 59) +// endregion + +// region Week +fun startOfWeek(date: LocalDate): LocalDate = + date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + +fun endOfWeek(date: LocalDate): LocalDate = + date.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)) +// endregion + +// region Month +fun startOfMonth(date: LocalDate): LocalDate = + date.withDayOfMonth(1) + +fun endOfMonth(date: LocalDate): LocalDate = + date.withDayOfMonth(date.lengthOfMonth()) + +fun LocalDate.withDayOfMonthSafe(targetDayOfMonth: Int): LocalDate { + val maxDayOfMonth = this.lengthOfMonth() + return this.withDayOfMonth( + if (targetDayOfMonth > maxDayOfMonth) maxDayOfMonth else targetDayOfMonth + ) +} +// endregion + +// region Year +fun startOfYear(date: LocalDate): LocalDate = + LocalDate.of(date.year, 1, 1) + +fun endOfYear(date: LocalDate): LocalDate = + LocalDate.of(date.year, 12, 31) +// endregion diff --git a/common/main/src/main/java/com/ivy/common/time/TimeConversion.kt b/common/main/src/main/java/com/ivy/common/time/TimeConversion.kt new file mode 100644 index 0000000..cbe599e --- /dev/null +++ b/common/main/src/main/java/com/ivy/common/time/TimeConversion.kt @@ -0,0 +1,18 @@ +package com.ivy.common.time + +import com.ivy.common.time.provider.TimeProvider +import java.time.Instant +import java.time.LocalDateTime + +fun Instant.toLocal(timeProvider: TimeProvider): LocalDateTime = + LocalDateTime.ofInstant(this, timeProvider.zoneId()) + +fun LocalDateTime.toUtc(timeProvider: TimeProvider): Instant = toInstant( + timeProvider.zoneId().rules.getOffset(this) +) + +fun LocalDateTime.toEpochMilli(timeProvider: TimeProvider): Long = + toUtc(timeProvider).toEpochMilli() + +fun LocalDateTime.toEpochSeconds(timeProvider: TimeProvider) = + toUtc(timeProvider).epochSecond \ No newline at end of file diff --git a/common/main/src/main/java/com/ivy/common/time/TimeExt.kt b/common/main/src/main/java/com/ivy/common/time/TimeExt.kt new file mode 100644 index 0000000..faf83c2 --- /dev/null +++ b/common/main/src/main/java/com/ivy/common/time/TimeExt.kt @@ -0,0 +1,106 @@ +package com.ivy.common.time + +import android.content.Context +import com.ivy.common.R +import com.ivy.common.time.provider.DeviceTimeProvider +import java.time.* +import java.time.format.DateTimeFormatter +import java.util.* + +// region Formatting +fun LocalDateTime.format(pattern: String): String = + this.format(DateTimeFormatter.ofPattern(pattern)) + +fun LocalTime.format(pattern: String): String = + this.format(DateTimeFormatter.ofPattern(pattern)) + +fun LocalDate.format(pattern: String): String = + this.format(DateTimeFormatter.ofPattern(pattern)) + +fun LocalTime.deviceFormat( + appContext: Context +): String = if (uses24HourFormat(appContext)) + format("HH:mm") else format("hh:mm a") +// endregion + +fun uses24HourFormat( + appContext: Context, +): Boolean = android.text.format.DateFormat.is24HourFormat(appContext) + +fun LocalDate.contextText( + alwaysShowWeekday: Boolean, + getString: (Int) -> String +): String { + val today = LocalDate.now() + val alwaysWeekdayText = if (alwaysShowWeekday) + " (${this.format(pattern = "EEEE")})" else "" + return when (this) { + today -> { + getString(R.string.today) + alwaysWeekdayText + } + today.minusDays(1) -> { + getString(R.string.yesterday) + alwaysWeekdayText + } + today.plusDays(1) -> { + getString(R.string.tomorrow) + alwaysWeekdayText + } + else -> { + this.format(pattern = "EEEE") + } + } +} + + +// region All-time +fun beginningOfIvyTime(): LocalDateTime = + LocalDateTime.of(1990, 1, 1, 0, 0) + +fun endOfIvyTime(): LocalDateTime = + LocalDateTime.of(2050, 1, 1, 0, 0) +// endregion + +fun LocalDate.dateId() = format("dd-MM-yyyy") + +fun deviceTimeProvider() = DeviceTimeProvider() + + +// region Deprecated (will be deleted) +@Deprecated("Use `TimeProvider` instead!") +fun timeNow(): LocalDateTime = LocalDateTime.now() + +@Deprecated("LocalDate and LocalTime must be indeed local!") +fun dateNowUTC(): LocalDate = LocalDate.now(ZoneOffset.UTC) + +@Deprecated("LocalDate and LocalTime must be indeed local!") +fun dateNowLocal(): LocalDate = LocalDate.now() + +@Deprecated("don't use") +fun startOfDayNowUTC(): LocalDateTime = dateNowUTC().atStartOfDay() + +@Deprecated("Don't use!") +fun Long.epochSecondToDateTime(): LocalDateTime = + LocalDateTime.ofEpochSecond(this, 0, ZoneOffset.UTC) + +@Deprecated("don't use") +fun Long.epochMilliToDateTime(): LocalDateTime = + Instant.ofEpochMilli(this).atZone(ZoneOffset.UTC).toLocalDateTime() + +@Deprecated("don't use") +fun LocalDate.formatDateOnly(): String = this.format("MMM. dd") + +@Deprecated("don't use") +fun LocalDateTime.convertUTCtoLocal(zone: ZoneId = ZoneOffset.systemDefault()): LocalDateTime { + return this.convertUTCto(zone) +} + +@Deprecated("don't use") +fun LocalDateTime.convertUTCto(zone: ZoneId): LocalDateTime { + return plusSeconds(atZone(zone).offset.totalSeconds.toLong()) +} + +@Deprecated("don't use") +fun LocalDateTime.convertLocalToUTC(): LocalDateTime { + val offset = timeNow().atZone(ZoneOffset.systemDefault()).offset.totalSeconds.toLong() + return this.minusSeconds(offset) +} +// endregion diff --git a/common/main/src/main/java/com/ivy/common/time/TimeRangeExt.kt b/common/main/src/main/java/com/ivy/common/time/TimeRangeExt.kt new file mode 100644 index 0000000..912caa7 --- /dev/null +++ b/common/main/src/main/java/com/ivy/common/time/TimeRangeExt.kt @@ -0,0 +1,6 @@ +package com.ivy.common.time + +import com.ivy.data.time.TimeRange +import java.time.LocalDateTime + +fun TimeRange.toPair(): Pair = from to to \ No newline at end of file diff --git a/common/main/src/main/java/com/ivy/common/time/TrnTimeExt.kt b/common/main/src/main/java/com/ivy/common/time/TrnTimeExt.kt new file mode 100644 index 0000000..ead8cad --- /dev/null +++ b/common/main/src/main/java/com/ivy/common/time/TrnTimeExt.kt @@ -0,0 +1,9 @@ +package com.ivy.common.time + +import com.ivy.data.transaction.TrnTime +import java.time.LocalDateTime + +fun TrnTime.time(): LocalDateTime = when (this) { + is TrnTime.Actual -> actual + is TrnTime.Due -> due +} \ No newline at end of file diff --git a/common/main/src/main/java/com/ivy/common/time/provider/DeviceTimeProvider.kt b/common/main/src/main/java/com/ivy/common/time/provider/DeviceTimeProvider.kt new file mode 100644 index 0000000..67ef3b9 --- /dev/null +++ b/common/main/src/main/java/com/ivy/common/time/provider/DeviceTimeProvider.kt @@ -0,0 +1,16 @@ +package com.ivy.common.time.provider + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DeviceTimeProvider @Inject constructor() : TimeProvider { + override fun timeNow(): LocalDateTime = LocalDateTime.now() + + override fun dateNow(): LocalDate = LocalDate.now() + + override fun zoneId(): ZoneId = ZoneId.systemDefault() +} \ No newline at end of file diff --git a/common/main/src/main/java/com/ivy/common/time/provider/TimeProvider.kt b/common/main/src/main/java/com/ivy/common/time/provider/TimeProvider.kt new file mode 100644 index 0000000..ccd45a4 --- /dev/null +++ b/common/main/src/main/java/com/ivy/common/time/provider/TimeProvider.kt @@ -0,0 +1,11 @@ +package com.ivy.common.time.provider + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId + +interface TimeProvider { + fun timeNow(): LocalDateTime + fun dateNow(): LocalDate + fun zoneId(): ZoneId +} \ No newline at end of file diff --git a/common/test/.gitignore b/common/test/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/common/test/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/common/test/build.gradle.kts b/common/test/build.gradle.kts new file mode 100644 index 0000000..be709ca --- /dev/null +++ b/common/test/build.gradle.kts @@ -0,0 +1,23 @@ +import com.ivy.buildsrc.Coroutines +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Kotlin +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + Testing( + // Prevent circular dependency + commonTest = false, + commonAndroidTest = false + ) + Kotlin(api = false) + Coroutines(api = false) +} \ No newline at end of file diff --git a/common/test/src/main/AndroidManifest.xml b/common/test/src/main/AndroidManifest.xml new file mode 100644 index 0000000..21bae0e --- /dev/null +++ b/common/test/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/README.md b/core/README.md new file mode 100644 index 0000000..a285d47 --- /dev/null +++ b/core/README.md @@ -0,0 +1,86 @@ +# Core module + +The `:core` module is responsible for [Ivy Wallet](https://play.google.com/store/apps/details?id=com.ivy.wallet)'s core features - accounts, transactions, categories and balance. + +**Structure** +- `:core:data-model`: the data classes representing Ivy Wallet's domain model. +- `:core:domain`: pure functions, "actions" _(write use-cases)_ and "flows" _(read uses-cases)_ implementing app's domain logic. +- `:core:persistence`: local persistence of the domain data via transformation to "entities" _(Room DB and DataStore)_. +- `:core:ui`: UI components representing the data model and key Ivy Wallet components. +- `:core:exchange-provider`: fetches latest exchange rate via API. + +## How does Ivy Wallet work? + +A brief overview at how our app is implemented. + +### Transactions + +Everything is a transaction! A transaction represents a movement of [Value](data-model/src/main/java/com/ivy/data/Value.kt) _(money, amount + currency)_. In the real world you can either get money which is `TrnType.Income` or spend money - `TrnType.Expense`. + +Everything in Ivy Wallet is represented using just "Income" and "Expense" transactions. + +### Accounts + +For a transaction _(movement of value)_ to happen: the value must either come from somewhere _(e.g. a pocket with cash when paying your rent)_ or go to somewhere _(e.g. a bank account when receiving your salary)_. + +Simply said, transactions must be stored somewhere and the perfect place for the is the [Account](data-model/src/main/java/com/ivy/data/account/Account.kt). + +### Balance + +Your balance is the sum of the balances of your accounts. + +> balance = $\Sigma$ of account balances + +The balance of an account is the sum of all Income (+) and Expense (-) transactions that have ever happened. + +> account balance = $\Sigma$ of incomes - $\Sigma$ of expenses + +The final piece of Ivy Wallet's domain logic is how do we handle transfer with just `Income` and `Expense` transactions? + +### Transfers (transactions batch) + +The simplest transfer that you can can do, right now, at your home is moving cash from your left pocket to your right one. + +If we imagine moving 5$ from the left pocket `Account Left` to right one `Account Right`, it can be described as: +- Transaction(type=Expense, acc=`Account Left`, value = 5$) +- Transation(type=Income, acc=`Account Right`, value = 5$) + +However, seeing two separate transactions in your transaction history is weird. Worse both your Income and Expense stats will be increased by $5. + +In reality, you didn't spend any money and didn't earn any money. That's why this case must be represented as `Transfer` in the UI. + +To achieve, Transfers while stil having simple and elegant data model, Ivy Wallet uses **"transaction batching"**. Simply, linking multiple transactions together. + +## Ivy Wallet behavior +Knowing how Ivy Wallet works under the hood, now let's observe its behavior from user's perspective. + +### Home: Balance +The balance that you see on your home scren is the sum of the balance of all **not excluded** accounts. + +> Home Balance = $\Sigma$ of **not** excluded account balances + +The idea behind that is that you can exlude all [non-liquid](https://www.bankrate.com/glossary/n/non-liquid-asset/) so that you'll see the money you have at your immediate disposal. + +_💡 Tip: To see your net worth (total balance with excluded accounts) just click the "Accounts" tab and it'll appear at the top._ + +### Home: Income & Expense + +Home's Income and expense are calculated by: +- including excluded accounts +- excluding transfer transactions + +**Formulas:** +- > Home Income = $\Sigma$ incomes from all accounts, excluding transfers + +- > Home Expense = $\Sigma$ expenses from all accounts, excluding transfers + +### Accounts' Income & Expense + +Income & Expense for accounts: +- > Account Income = $\Sigma$ all incomes, including transfers in +- > Account Expense = $\Sigma$ all expenses, including transfers out + +### Categories' Income & Expense + +- > Category Income = $\Sigma$ all incomes, excluding transfers +- > Category Expense = $\Sigma$ all expenses, excluding transfers diff --git a/core/data-model/.gitignore b/core/data-model/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core/data-model/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/data-model/README.md b/core/data-model/README.md new file mode 100644 index 0000000..89f4999 --- /dev/null +++ b/core/data-model/README.md @@ -0,0 +1,3 @@ +# [Core] Data Model + +The `data classes` of Ivy Wallet representing its domain model. \ No newline at end of file diff --git a/core/data-model/build.gradle.kts b/core/data-model/build.gradle.kts new file mode 100644 index 0000000..ea6caf2 --- /dev/null +++ b/core/data-model/build.gradle.kts @@ -0,0 +1,14 @@ +import com.ivy.buildsrc.Arrow +import com.ivy.buildsrc.Hilt + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + Arrow(false) +} \ No newline at end of file diff --git a/core/data-model/src/main/AndroidManifest.xml b/core/data-model/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3bda17d --- /dev/null +++ b/core/data-model/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/core/data/Account.kt b/core/data-model/src/main/java/com/ivy/core/data/Account.kt new file mode 100644 index 0000000..ea8cea6 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/core/data/Account.kt @@ -0,0 +1,208 @@ +package com.ivy.core.data + +import androidx.annotation.FloatRange +import com.ivy.core.data.common.* +import com.ivy.core.data.sync.Syncable +import com.ivy.core.data.sync.UniqueId +import java.time.LocalDateTime +import java.util.* + +// TODO: Support attachments to Accounts? Some users wanted to attach loan documents +sealed interface Account : Reorderable, Archiveable, Syncable { + override val id: AccountId + val asset: AssetCode + val name: String + val description: String? + val iconId: ItemIconId + val color: IvyColor + val includeInBalance: Boolean + val folderId: AccountFolderId? +} + +sealed interface Asset : Account { + data class Cash( + override val id: AccountId, + override val asset: AssetCode, + override val name: String, + override val description: String?, + override val iconId: ItemIconId, + override val color: IvyColor, + override val includeInBalance: Boolean, + override val folderId: AccountFolderId?, + override val orderNum: Double, + override val archived: Boolean, + override val lastUpdated: LocalDateTime, + override val removed: Boolean, + ) : Asset + + data class Bank( + override val id: AccountId, + override val asset: AssetCode, + override val name: String, + override val description: String?, + override val iconId: ItemIconId, + override val color: IvyColor, + override val includeInBalance: Boolean, + override val folderId: AccountFolderId?, + override val orderNum: Double, + override val archived: Boolean, + override val lastUpdated: LocalDateTime, + override val removed: Boolean, + ) : Asset + + /** + * You gave a Loan to someone and they owe you money. + */ + data class Loan( + override val id: AccountId, + override val asset: AssetCode, + override val name: String, + override val description: String?, + override val iconId: ItemIconId, + override val color: IvyColor, + override val includeInBalance: Boolean, + override val folderId: AccountFolderId?, + override val orderNum: Double, + override val archived: Boolean, + override val lastUpdated: LocalDateTime, + override val removed: Boolean, + + /** + * The lent amount + */ + val principal: Value, + @FloatRange(from = 0.0, to = 1.0) + val interest: Float, + ) : Asset + + /** + * Illiquid asset like Stocks, Crypto, Gold. + */ + data class Investment( + override val id: AccountId, + override val asset: AssetCode, + override val name: String, + override val description: String?, + override val iconId: ItemIconId, + override val color: IvyColor, + override val includeInBalance: Boolean, + override val folderId: AccountFolderId?, + override val orderNum: Double, + override val archived: Boolean, + override val lastUpdated: LocalDateTime, + override val removed: Boolean, + ) : Asset + + /** + * Money put in Savings account. We don't know if they are liquid + */ + data class Savings( + override val id: AccountId, + override val asset: AssetCode, + override val name: String, + override val description: String?, + override val iconId: ItemIconId, + override val color: IvyColor, + override val includeInBalance: Boolean, + override val folderId: AccountFolderId?, + override val orderNum: Double, + override val archived: Boolean, + override val lastUpdated: LocalDateTime, + override val removed: Boolean, + ) : Asset + + data class Other( + override val id: AccountId, + override val asset: AssetCode, + override val name: String, + override val description: String?, + override val iconId: ItemIconId, + override val color: IvyColor, + override val includeInBalance: Boolean, + override val folderId: AccountFolderId?, + override val orderNum: Double, + override val archived: Boolean, + override val lastUpdated: LocalDateTime, + override val removed: Boolean, + ) : Asset +} + +sealed interface Liability : Account { + data class CreditCard( + override val id: AccountId, + override val asset: AssetCode, + override val name: String, + override val description: String?, + override val iconId: ItemIconId, + override val color: IvyColor, + override val includeInBalance: Boolean, + override val folderId: AccountFolderId?, + override val orderNum: Double, + override val archived: Boolean, + override val lastUpdated: LocalDateTime, + override val removed: Boolean, + + val limit: Value, + val billingDate: MonthDate, + val dueDate: MonthDate, + ) : Liability + + /** + * Money that you owe to a bank, friend or other entity. + */ + data class Loan( + override val id: AccountId, + override val asset: AssetCode, + override val name: String, + override val description: String?, + override val iconId: ItemIconId, + override val color: IvyColor, + override val includeInBalance: Boolean, + override val folderId: AccountFolderId?, + override val orderNum: Double, + override val archived: Boolean, + override val lastUpdated: LocalDateTime, + override val removed: Boolean, + + /** + * The borrowed amount + */ + val principal: Value, + @FloatRange(from = 0.0, to = 1.0) + val interest: Float, + ) : Liability + + data class Other( + override val id: AccountId, + override val asset: AssetCode, + override val name: String, + override val description: String?, + override val iconId: ItemIconId, + override val color: IvyColor, + override val includeInBalance: Boolean, + override val folderId: AccountFolderId?, + override val orderNum: Double, + override val archived: Boolean, + override val lastUpdated: LocalDateTime, + override val removed: Boolean, + ) : Liability +} + + +@JvmInline +value class AccountId(override val uuid: UUID) : UniqueId + +@JvmInline +value class AccountFolderId(override val uuid: UUID) : UniqueId + +data class AccountFolder( + override val id: UniqueId, + val asset: AssetCode, + val name: String, + val description: String?, + val iconId: ItemIconId, + val color: IvyColor, + override val orderNum: Double, + override val lastUpdated: LocalDateTime, + override val removed: Boolean, +) : Reorderable, Syncable diff --git a/core/data-model/src/main/java/com/ivy/core/data/Attachment.kt b/core/data-model/src/main/java/com/ivy/core/data/Attachment.kt new file mode 100644 index 0000000..32d90e2 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/core/data/Attachment.kt @@ -0,0 +1,31 @@ +package com.ivy.core.data + +import com.ivy.core.data.sync.Syncable +import com.ivy.core.data.sync.UniqueId +import java.net.URL +import java.time.LocalDateTime +import java.util.* + +data class Attachment( + override val id: AttachmentId, + val url: URL, + val name: String, + val type: AttachmentType, + override val lastUpdated: LocalDateTime, + override val removed: Boolean, +) : Syncable + +enum class AttachmentType(val code: Int) { + Image(1), + Other(100); + + companion object { + fun fromCode(code: Int): AttachmentType = when (code) { + 1 -> Image + else -> Other + } + } +} + +@JvmInline +value class AttachmentId(override val uuid: UUID) : UniqueId \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/core/data/Budget.kt b/core/data-model/src/main/java/com/ivy/core/data/Budget.kt new file mode 100644 index 0000000..c0cc9b6 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/core/data/Budget.kt @@ -0,0 +1,37 @@ +package com.ivy.core.data + +import arrow.core.NonEmptyList +import com.ivy.core.data.common.* +import com.ivy.core.data.sync.Syncable +import com.ivy.core.data.sync.UniqueId +import java.time.LocalDateTime +import java.util.* + +data class Budget( + override val id: BudgetId, + val name: String, + val description: String?, + val iconId: ItemIconId, + val color: IvyColor, + val amount: Value, + val carryOver: Boolean, + val period: TimePeriod, + val categories: BudgetCategories, + val accounts: BudgetAccounts, + override val orderNum: Double, + override val lastUpdated: LocalDateTime, + override val removed: Boolean, +) : Reorderable, Syncable + +sealed interface BudgetCategories { + object All : BudgetCategories + data class Specific(val ids: NonEmptyList) : BudgetCategories +} + +sealed interface BudgetAccounts { + object All : BudgetAccounts + data class Specific(val ids: NonEmptyList) : BudgetAccounts +} + +@JvmInline +value class BudgetId(override val uuid: UUID) : UniqueId diff --git a/core/data-model/src/main/java/com/ivy/core/data/Category.kt b/core/data-model/src/main/java/com/ivy/core/data/Category.kt new file mode 100644 index 0000000..09e0216 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/core/data/Category.kt @@ -0,0 +1,31 @@ +package com.ivy.core.data + +import com.ivy.core.data.common.Archiveable +import com.ivy.core.data.common.ItemIconId +import com.ivy.core.data.common.IvyColor +import com.ivy.core.data.common.Reorderable +import com.ivy.core.data.sync.Syncable +import com.ivy.core.data.sync.UniqueId +import java.time.LocalDateTime +import java.util.* + +data class Category( + override val id: CategoryId, + val name: String, + val description: String?, + val iconId: ItemIconId, + val color: IvyColor, + val type: CategoryType, + val parentCategory: CategoryId?, + override val orderNum: Double, + override val archived: Boolean, + override val lastUpdated: LocalDateTime, + override val removed: Boolean, +) : Reorderable, Archiveable, Syncable + +@JvmInline +value class CategoryId(override val uuid: UUID) : UniqueId + +enum class CategoryType { + Income, Expense, Both +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/core/data/RecurringRule.kt b/core/data-model/src/main/java/com/ivy/core/data/RecurringRule.kt new file mode 100644 index 0000000..c6146f0 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/core/data/RecurringRule.kt @@ -0,0 +1,22 @@ +package com.ivy.core.data + +import arrow.core.NonEmptyList +import com.ivy.core.data.common.RepeatInterval +import com.ivy.core.data.sync.Syncable +import com.ivy.core.data.sync.UniqueId +import java.time.LocalDateTime +import java.util.* + +data class RecurringRule( + override val id: RecurringRuleId, + val transaction: Transaction, + val starting: LocalDateTime, + val repeating: NonEmptyList, + val end: LocalDateTime?, + val autoExecute: Boolean, + override val lastUpdated: LocalDateTime, + override val removed: Boolean, +) : Syncable + +@JvmInline +value class RecurringRuleId(override val uuid: UUID) : UniqueId \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/core/data/SavingGoal.kt b/core/data-model/src/main/java/com/ivy/core/data/SavingGoal.kt new file mode 100644 index 0000000..5c3660c --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/core/data/SavingGoal.kt @@ -0,0 +1,46 @@ +package com.ivy.core.data + +import com.ivy.core.data.common.Archiveable +import com.ivy.core.data.common.ItemIconId +import com.ivy.core.data.common.Reorderable +import com.ivy.core.data.common.Value +import com.ivy.core.data.sync.Syncable +import com.ivy.core.data.sync.UniqueId +import java.time.LocalDateTime +import java.util.* + +data class SavingGoal( + override val id: SavingGoalId, + val name: String, + val description: String?, + val url: String?, + val iconId: ItemIconId, + val amount: Value, + val deadline: LocalDateTime, + override val orderNum: Double, + override val archived: Boolean, + override val lastUpdated: LocalDateTime, + override val removed: Boolean, +) : Reorderable, Archiveable, Syncable + +@JvmInline +value class SavingGoalId(override val uuid: UUID) : UniqueId + +data class SavingGoalRecord( + override val id: SavingGoalRecordId, + val savingGoalId: SavingGoalId, + val accountId: AccountId, + val type: SavingGoalRecordType, + val amount: Value, + val title: String?, + val description: String?, + override val lastUpdated: LocalDateTime, + override val removed: Boolean, +) : Syncable + +@JvmInline +value class SavingGoalRecordId(override val uuid: UUID) : UniqueId + +enum class SavingGoalRecordType { + Deposit, Withdraw +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/core/data/Tag.kt b/core/data-model/src/main/java/com/ivy/core/data/Tag.kt new file mode 100644 index 0000000..bccea8f --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/core/data/Tag.kt @@ -0,0 +1,23 @@ +package com.ivy.core.data + +import com.ivy.core.data.common.Archiveable +import com.ivy.core.data.common.IvyColor +import com.ivy.core.data.common.Reorderable +import com.ivy.core.data.sync.Syncable +import com.ivy.core.data.sync.UniqueId +import java.time.LocalDateTime +import java.util.* + +data class Tag( + override val id: TagId, + val name: String, + val description: String?, + val color: IvyColor, + override val orderNum: Double, + override val archived: Boolean, + override val lastUpdated: LocalDateTime, + override val removed: Boolean, +) : Reorderable, Archiveable, Syncable + +@JvmInline +value class TagId(override val uuid: UUID) : UniqueId \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/core/data/Transaction.kt b/core/data-model/src/main/java/com/ivy/core/data/Transaction.kt new file mode 100644 index 0000000..7e2b492 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/core/data/Transaction.kt @@ -0,0 +1,90 @@ +package com.ivy.core.data + +import com.ivy.core.data.common.Value +import com.ivy.core.data.sync.Syncable +import com.ivy.core.data.sync.UniqueId +import java.time.LocalDateTime +import java.util.* + +sealed interface Transaction : Syncable { + override val id: TransactionId + val fee: Value? + val time: TransactionTime + val category: CategoryId? + val tags: List + val attachments: List + val title: String? + val description: String? + val hidden: Boolean + val autoAdded: Boolean + val recurring: RecurringRuleId? + + data class Income( + override val id: TransactionId, + val account: AccountId, + val amount: Value, + override val fee: Value?, + override val time: TransactionTime, + override val category: CategoryId?, + override val tags: List, + override val attachments: List, + override val title: String?, + override val description: String?, + override val hidden: Boolean, + override val autoAdded: Boolean, + override val recurring: RecurringRuleId?, + override val lastUpdated: LocalDateTime, + override val removed: Boolean, + ) : Transaction + + data class Expense( + override val id: TransactionId, + val account: AccountId, + val amount: Value, + override val fee: Value?, + override val time: TransactionTime, + override val category: CategoryId?, + override val tags: List, + override val attachments: List, + override val title: String?, + override val description: String?, + override val hidden: Boolean, + override val autoAdded: Boolean, + override val recurring: RecurringRuleId?, + override val lastUpdated: LocalDateTime, + override val removed: Boolean, + ) : Transaction + + data class Transfer( + override val id: TransactionId, + val from: AccountValue, + val to: AccountValue, + override val fee: Value?, + override val time: TransactionTime, + override val category: CategoryId?, + override val tags: List, + override val attachments: List, + override val title: String?, + override val description: String?, + override val hidden: Boolean, + override val autoAdded: Boolean, + override val recurring: RecurringRuleId?, + override val lastUpdated: LocalDateTime, + override val removed: Boolean, + ) : Transaction +} + +sealed interface TransactionTime { + val time: LocalDateTime + + data class Actual(override val time: LocalDateTime) : TransactionTime + data class Due(override val time: LocalDateTime) : TransactionTime +} + +@JvmInline +value class TransactionId(override val uuid: UUID) : UniqueId + +data class AccountValue( + val account: AccountId, + val value: Value, +) \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/core/data/calculation/AccountCache.kt b/core/data-model/src/main/java/com/ivy/core/data/calculation/AccountCache.kt new file mode 100644 index 0000000..057428f --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/core/data/calculation/AccountCache.kt @@ -0,0 +1,8 @@ +package com.ivy.core.data.calculation + +import com.ivy.core.data.AccountId + +data class AccountCache( + val accountId: AccountId, + val rawStats: RawStats, +) \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/core/data/calculation/ExchangeRates.kt b/core/data-model/src/main/java/com/ivy/core/data/calculation/ExchangeRates.kt new file mode 100644 index 0000000..34aef64 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/core/data/calculation/ExchangeRates.kt @@ -0,0 +1,28 @@ +package com.ivy.core.data.calculation + +import com.ivy.core.data.common.AssetCode +import com.ivy.core.data.common.PositiveDouble + +/** + * Provides exchange rates for a given base. + * A rate tells you how much **1 "CODE" asset unit** is worth in **Y "BASE" asset**. + * + * Example: + * ``` + * { + * base: "BGN", + * rates: { + * "EUR": 0.51, + * "USD": 0.56, + * "BGN": 1.0 + * } + * } + * ``` + * + * Exchange rates of 0 aren't allowed because they can lead to [Double.POSITIVE_INFINITY] or + * [Double.NaN]. + */ +data class ExchangeRates( + val base: AssetCode, + val rates: Map +) \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/core/data/calculation/RawStats.kt b/core/data-model/src/main/java/com/ivy/core/data/calculation/RawStats.kt new file mode 100644 index 0000000..9f6ca31 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/core/data/calculation/RawStats.kt @@ -0,0 +1,14 @@ +package com.ivy.core.data.calculation + +import com.ivy.core.data.common.AssetCode +import com.ivy.core.data.common.NonNegativeInt +import com.ivy.core.data.common.PositiveDouble +import java.time.LocalDateTime + +data class RawStats( + val incomes: Map, + val expenses: Map, + val incomesCount: NonNegativeInt, + val expensesCount: NonNegativeInt, + val newestTransaction: LocalDateTime, +) \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/core/data/common/Archiveable.kt b/core/data-model/src/main/java/com/ivy/core/data/common/Archiveable.kt new file mode 100644 index 0000000..880ea6b --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/core/data/common/Archiveable.kt @@ -0,0 +1,5 @@ +package com.ivy.core.data.common + +interface Archiveable { + val archived: Boolean +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/core/data/common/ItemVisuals.kt b/core/data-model/src/main/java/com/ivy/core/data/common/ItemVisuals.kt new file mode 100644 index 0000000..e3c4d3c --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/core/data/common/ItemVisuals.kt @@ -0,0 +1,16 @@ +package com.ivy.core.data.common + +import androidx.annotation.ColorInt + +/** + * A unique [String] id representing an Ivy icon. + * Like "car", "ic_vue_building_bank", "awesomeicon3". + */ +@JvmInline +value class ItemIconId(val id: String) + +/** + * A packed int (AARRGGBB) representation of an color using [ColorInt]. + */ +@JvmInline +value class IvyColor(@ColorInt val color: Int) diff --git a/core/data-model/src/main/java/com/ivy/core/data/common/Reorderable.kt b/core/data-model/src/main/java/com/ivy/core/data/common/Reorderable.kt new file mode 100644 index 0000000..466ce1a --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/core/data/common/Reorderable.kt @@ -0,0 +1,9 @@ +package com.ivy.core.data.common + +/** + * Indicates that the item can be reordered by the user. + * For the reordering to happen efficiently the item must have an [orderNum]. + */ +interface Reorderable { + val orderNum: Double +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/core/data/common/Time.kt b/core/data-model/src/main/java/com/ivy/core/data/common/Time.kt new file mode 100644 index 0000000..2e61b61 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/core/data/common/Time.kt @@ -0,0 +1,119 @@ +package com.ivy.core.data.common + +import java.time.LocalDateTime + +enum class WeekDay(val value: Int) { + Monday(1), + Tuesday(2), + Wednesday(3), + Thursday(4), + Friday(5), + Saturday(6), + Sunday(7); + + companion object { + fun new(value: Int): WeekDay = when (value) { + 1 -> Monday + 2 -> Tuesday + 3 -> Wednesday + 4 -> Thursday + 5 -> Friday + 6 -> Saturday + 7 -> Sunday + else -> error("WeekDay error: Invalid week day with number $value.") + } + } +} + +/** + * An int between 1 and 31 inclusively representing a date in a month. + * Use [MonthDate.of] to create one. + */ +@JvmInline +value class MonthDate private constructor(val value: Int) { + companion object { + /** + * @throws error if the int isn't between 1 and 31 + * @return a valid [MonthDate] + */ + fun of(value: Int): MonthDate = if (value in 1..31) + MonthDate(value) else error("MonthDate error: $value is not a valid date in a month") + } +} + +enum class Month(val value: Int) { + January(1), + February(2), + March(3), + April(4), + May(5), + June(6), + July(7), + August(8), + September(9), + October(10), + November(11), + December(12); + + companion object { + fun new(value: Int): Month = when (value) { + 1 -> January + 2 -> February + 3 -> March + 4 -> April + 5 -> May + 6 -> June + 7 -> July + 8 -> August + 9 -> September + 10 -> October + 11 -> November + 12 -> December + else -> error("Month error: Invalid month with number $value.") + } + } +} + +data class YearDate( + val month: Month, + val date: MonthDate, +) + +data class TimeRange( + val from: LocalDateTime, + val to: LocalDateTime +) + +sealed interface TimePeriod { + data class Fixed(val range: TimeRange) : TimePeriod + + sealed interface Calendar : TimePeriod { + object Daily : Calendar + data class Weekly(val startDay: WeekDay) : Calendar + data class Monthly(val startDate: MonthDate) : Calendar + data class Yearly(val startMonth: Month, val startDate: MonthDate) : Calendar + } +} + +sealed interface RepeatInterval { + data class Fixed( + val intervalSeconds: Long + ) : RepeatInterval + + sealed interface Calendar : RepeatInterval { + object Daily : Calendar + + data class Weekly( + val day: WeekDay + ) : RepeatInterval + + data class Monthly( + val date: MonthDate + ) : RepeatInterval + + data class Yearly( + val month: Month, + val date: MonthDate + ) : RepeatInterval + } +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/core/data/common/Types.kt b/core/data-model/src/main/java/com/ivy/core/data/common/Types.kt new file mode 100644 index 0000000..33aae28 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/core/data/common/Types.kt @@ -0,0 +1,115 @@ +package com.ivy.core.data.common + +import arrow.core.Option +import arrow.core.getOrElse +import arrow.core.toOption + +/** + * Int that is **>=0** and in the range [0, [Int.MAX_VALUE]]. + * Use [NonNegativeInt.fromIntUnsafe] or [Int.toNonNegativeUnsafe] to create one. + */ +@JvmInline +value class NonNegativeInt private constructor(val value: Int) { + companion object { + /** + * @throws error if the [Int] isn't positive: **this >= 0** + * @return a valid [NonNegativeInt] + */ + fun fromIntUnsafe(value: Int): NonNegativeInt = fromInt(value).getOrElse { + error("PositiveInt error: $value is not a non-negative (>= 0) number.") + } + + fun fromInt(value: Int): Option = + value.takeIf { it >= 0.0 }.toOption().map(::NonNegativeInt) + } +} + + +/** + * See [NonNegativeInt.fromInt] and [NonNegativeInt.fromIntUnsafe]. + */ +fun Int.toNonNegativeUnsafe(): NonNegativeInt = NonNegativeInt.fromIntUnsafe(this) +fun Int.toNonNegative(): Option = NonNegativeInt.fromInt(this) + + +/** + * Int that is **>0** and in the range [1, [Int.MAX_VALUE]]. + * Use [PositiveInt.fromIntUnsafe] or [Int.toPositiveUnsafe] to create one. + */ +@JvmInline +value class PositiveInt private constructor(val value: Int) { + companion object { + /** + * @throws error if the [Int] isn't positive: **this > 0** + * @return a valid [PositiveInt] + */ + fun fromIntUnsafe(value: Int): PositiveInt = fromInt(value).getOrElse { + error("PositiveInt error: $value is not a positive (> 0) number.") + } + + fun fromInt(value: Int): Option = + value.takeIf { it > 0.0 }.toOption().map(::PositiveInt) + } +} + +/** + * See [PositiveInt.fromIntUnsafe]. + */ +fun Int.toPositiveUnsafe(): PositiveInt = PositiveInt.fromIntUnsafe(this) +fun Int.toPositive(): Option = PositiveInt.fromInt(this) + + +/** + * Double that is **>=0** and in the range [0, [Double.MAX_VALUE]]. + * Use [NonNegativeDouble.fromDoubleUnsafe] or [Double.toNonNegativeUnsafe] to create one. + */ +@JvmInline +value class NonNegativeDouble private constructor(val value: Double) { + companion object { + /** + * @throws error if the [Double] isn't positive: **this >= 0** + * @return a valid [NonNegativeDouble] + */ + fun fromDoubleUnsafe(value: Double): NonNegativeDouble = + fromDouble(value).getOrElse { + error("NonNegativeDouble error: $value is not a non-negative (>= 0) number.") + } + + fun fromDouble(value: Double): Option = + value.takeIf { it >= 0.0 }.toOption().map(::NonNegativeDouble) + } +} + +/** + * See [NonNegativeDouble.fromDoubleUnsafe]. + */ +fun Double.toNonNegativeUnsafe(): NonNegativeDouble = NonNegativeDouble.fromDoubleUnsafe(this) +fun Double.toNonNegative(): Option = NonNegativeDouble.fromDouble(this) + + +/** + * Double that is **>0** and in the range (0, [Double.MAX_VALUE]]. + * Use [PositiveDouble.fromDoubleUnsafe] or [Double.toPositiveUnsafe] to create one. + */ +@JvmInline +value class PositiveDouble private constructor(val value: Double) { + companion object { + /** + * @throws error if the [Double] isn't positive: **this > 0** + * @return a valid [PositiveDouble] + */ + fun fromDoubleUnsafe(value: Double): PositiveDouble = fromDouble(value).getOrElse { + error("PositiveDouble error: $value is not a positive (> 0) number.") + } + + fun fromDouble(value: Double): Option = + value.takeIf { it > 0.0 }.toOption().map(::PositiveDouble) + + } +} + +/** + * See [PositiveDouble.fromDoubleUnsafe]. + */ +fun Double.toPositiveUnsafe(): PositiveDouble = PositiveDouble.fromDoubleUnsafe(this) +fun Double.toPositive(): Option = PositiveDouble.fromDouble(this) \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/core/data/common/Value.kt b/core/data-model/src/main/java/com/ivy/core/data/common/Value.kt new file mode 100644 index 0000000..0d72563 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/core/data/common/Value.kt @@ -0,0 +1,46 @@ +package com.ivy.core.data.common + +import arrow.core.Option +import arrow.core.getOrElse +import arrow.core.toOption + +/** + * Represents monetary value. (like 10 USD, 5 EUR, 0.005 BTC, 12 GOLD_GRAM) + */ +data class Value( + val amount: PositiveDouble, + val asset: AssetCode, +) + +/** + * A unique string code representing an asset: + * - fiat currency (like EUR, USD, GBP) + * - crypto currency (like BTC, ETH, ADA) + * - something abstract (like GOLD, WATER, BMW) + * + * Use [AssetCode.fromStringUnsafe] to create one. + */ +@JvmInline +value class AssetCode private constructor(val code: String) { + companion object { + /** + * @throws error if the code is blank + * @return valid trimmed [AssetCode] + */ + fun fromStringUnsafe(code: String): AssetCode = fromString(code).getOrElse { + error("AssetCode error: code cannot be blank!") + } + + fun fromString(code: String): Option = + code.takeIf { it.isNotBlank() }.toOption() + .map { AssetCode(it.trim()) } + } +} + +sealed interface SignedValue { + val value: Value + + data class Positive(override val value: Value) : SignedValue + data class Negative(override val value: Value) : SignedValue + data class Zero(override val value: Value) : SignedValue +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/core/data/optimized/LedgerEntry.kt b/core/data-model/src/main/java/com/ivy/core/data/optimized/LedgerEntry.kt new file mode 100644 index 0000000..d7a0713 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/core/data/optimized/LedgerEntry.kt @@ -0,0 +1,28 @@ +package com.ivy.core.data.optimized + +import com.ivy.core.data.AccountValue +import com.ivy.core.data.common.Value +import java.time.LocalDateTime + +sealed interface LedgerEntry { + val time: LocalDateTime + + sealed interface Single : LedgerEntry { + data class Income( + val value: Value, + override val time: LocalDateTime, + ) : Single + + data class Expense( + val value: Value, + override val time: LocalDateTime, + ) : Single + + } + + data class Transfer( + val from: AccountValue, + val to: AccountValue, + override val time: LocalDateTime, + ) : LedgerEntry +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/core/data/sync/IvyWalletData.kt b/core/data-model/src/main/java/com/ivy/core/data/sync/IvyWalletData.kt new file mode 100644 index 0000000..ef58eca --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/core/data/sync/IvyWalletData.kt @@ -0,0 +1,33 @@ +package com.ivy.core.data.sync + +import com.ivy.core.data.* + + +data class IvyWalletData( + val accounts: SyncData, + val transactions: SyncData, + val categories: SyncData, + val tags: SyncData, + val recurringRules: SyncData, + val attachments: SyncData, + val budgets: SyncData, + val savingGoals: SyncData, + val savingGoalRecords: SyncData, +) + +data class PartialIvyWalletData( + val accounts: SyncData, + val transactions: SyncData, + val categories: SyncData, + val tags: SyncData, + val recurringRules: SyncData, + val attachments: SyncData, + val budgets: SyncData, + val savingGoals: SyncData, + val savingGoalRecords: SyncData, +) + +data class SyncData( + val items: List, + val deleted: Set +) diff --git a/core/data-model/src/main/java/com/ivy/core/data/sync/Syncable.kt b/core/data-model/src/main/java/com/ivy/core/data/sync/Syncable.kt new file mode 100644 index 0000000..8ea7892 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/core/data/sync/Syncable.kt @@ -0,0 +1,13 @@ +package com.ivy.core.data.sync + +import java.time.LocalDateTime + +interface Syncable { + val id: UniqueId + val lastUpdated: LocalDateTime + + /** + * Tombstone flag + */ + val removed: Boolean +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/core/data/sync/UniqueId.kt b/core/data-model/src/main/java/com/ivy/core/data/sync/UniqueId.kt new file mode 100644 index 0000000..49b5227 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/core/data/sync/UniqueId.kt @@ -0,0 +1,7 @@ +package com.ivy.core.data.sync + +import java.util.* + +interface UniqueId { + val uuid: UUID +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/IvyCurrency.kt b/core/data-model/src/main/java/com/ivy/data/IvyCurrency.kt new file mode 100644 index 0000000..5e39b65 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/IvyCurrency.kt @@ -0,0 +1,221 @@ +package com.ivy.data + +import android.icu.util.Currency +import java.util.* + +data class IvyCurrency( + val code: CurrencyCode, + val name: String, + val isCrypto: Boolean +) { + companion object { + val CRYPTO = setOf( + IvyCurrency( + code = "BTC", + name = "Bitcoin", + isCrypto = true + ), + IvyCurrency( + code = "ETH", + name = "Ethereum", + isCrypto = true + ), + IvyCurrency( + code = "USDT", + name = "Tether USD", + isCrypto = true + ), + IvyCurrency( + code = "BNB", + name = "Binance Coin", + isCrypto = true + ), + IvyCurrency( + code = "ADA", + name = "Cardano", + isCrypto = true + ), + IvyCurrency( + code = "XRP", + name = "Ripple", + isCrypto = true + ), + IvyCurrency( + code = "DOGE", + name = "Dogecoin", + isCrypto = true + ), + IvyCurrency( + code = "USDC", + name = "USD Coin", + isCrypto = true + ), + IvyCurrency( + code = "DOT", + name = "Polkadot", + isCrypto = true + ), + IvyCurrency( + code = "UNI", + name = "Uniswap", + isCrypto = true + ), + IvyCurrency( + code = "BUSD", + name = "Binance USD", + isCrypto = true + ), + IvyCurrency( + code = "BCH", + name = "Bitcoin Cash", + isCrypto = true + ), + IvyCurrency( + code = "SOL", + name = "Solana", + isCrypto = true + ), + IvyCurrency( + code = "LTC", + name = "Litecoin", + isCrypto = true + ), + IvyCurrency( + code = "LINK", + name = "ChainLink Token", + isCrypto = true + ), + IvyCurrency( + code = "SHIB", + name = "Shiba Inu coin", + isCrypto = true + ), + IvyCurrency( + code = "LUNA", + name = "Terra", + isCrypto = true + ), + IvyCurrency( + code = "AVAX", + name = "Avalanche", + isCrypto = true + ), + IvyCurrency( + code = "MATIC", + name = "Polygon", + isCrypto = true + ), + IvyCurrency( + code = "CRO", + name = "Cronos", + isCrypto = true + ), + IvyCurrency( + code = "WBTC", + name = "Wrapped Bitcoin", + isCrypto = true + ), + IvyCurrency( + code = "ALGO", + name = "Algorand", + isCrypto = true + ), + IvyCurrency( + code = "XLM", + name = "Stellar", + isCrypto = true + ), + IvyCurrency( + code = "MANA", + name = "Decentraland", + isCrypto = true + ), + IvyCurrency( + code = "AXS", + name = "Axie Infinity", + isCrypto = true + ), + IvyCurrency( + code = "DAI", + name = "Dai", + isCrypto = true + ), + IvyCurrency( + code = "ICP", + name = "Internet Computer", + isCrypto = true + ), + IvyCurrency( + code = "ATOM", + name = "Cosmos", + isCrypto = true + ), + IvyCurrency( + code = "FIL", + name = "Filecoin", + isCrypto = true + ), + IvyCurrency( + code = "ETC", + name = "Ethereum Classic", + isCrypto = true + ), + IvyCurrency( + code = "DASH", + name = "Dash", + isCrypto = true + ), + IvyCurrency( + code = "TRX", + name = "Tron", + isCrypto = true + ), + ) + + fun getAvailable(): List { + return Currency.getAvailableCurrencies() + .map { + IvyCurrency( + code = it.currencyCode, + name = it.displayName, + isCrypto = false + ) + } + .plus(CRYPTO) + } + + fun fromCode(code: String): IvyCurrency? { + if (code.isBlank()) return null + + val crypto = CRYPTO.find { it.code == code } + if (crypto != null) { + return crypto + } + + return try { + val fiat = Currency.getInstance(code) + IvyCurrency( + fiatCurrency = fiat + ) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + fun getDefault(): IvyCurrency = IvyCurrency( + fiatCurrency = getDefaultFIATCurrency() + ) + } + + constructor(fiatCurrency: Currency) : this( + code = fiatCurrency.currencyCode, + name = fiatCurrency.displayName, + isCrypto = false + ) +} + +fun getDefaultFIATCurrency(): Currency = + Currency.getInstance(Locale.getDefault()) ?: Currency.getInstance("USD") + ?: Currency.getInstance("usd") ?: Currency.getAvailableCurrencies().firstOrNull() + ?: Currency.getInstance("EUR") diff --git a/core/data-model/src/main/java/com/ivy/data/Sync.kt b/core/data-model/src/main/java/com/ivy/data/Sync.kt new file mode 100644 index 0000000..36d6429 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/Sync.kt @@ -0,0 +1,8 @@ +package com.ivy.data + +import java.time.LocalDateTime + +data class Sync( + val state: SyncState, + val lastUpdated: LocalDateTime, +) \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/SyncState.kt b/core/data-model/src/main/java/com/ivy/data/SyncState.kt new file mode 100644 index 0000000..a2ba99d --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/SyncState.kt @@ -0,0 +1,14 @@ +package com.ivy.data + +const val SYNCED = 1 +const val SYNCING = 2 +const val DELETING = 3 + +@Deprecated("will be removed!") +enum class SyncState(val code: Int) { + Synced(SYNCED), Syncing(SYNCING), Deleting(DELETING); + + companion object { + fun fromCode(code: Int): SyncState? = values().firstOrNull { it.code == code } + } +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/Theme.kt b/core/data-model/src/main/java/com/ivy/data/Theme.kt new file mode 100644 index 0000000..583d016 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/Theme.kt @@ -0,0 +1,12 @@ +package com.ivy.data + +import androidx.compose.runtime.Immutable + +@Immutable +enum class Theme(val code: Int) { + Light(1), Dark(-1), Auto(0); + + companion object { + fun fromCode(code: Int): Theme = values().first { it.code == code } + } +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/Types.kt b/core/data-model/src/main/java/com/ivy/data/Types.kt new file mode 100644 index 0000000..3259eeb --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/Types.kt @@ -0,0 +1,7 @@ +package com.ivy.data + +typealias ItemIconId = String + +typealias CurrencyCode = String + +typealias ExchangeRatesMap = Map \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/Value.kt b/core/data-model/src/main/java/com/ivy/data/Value.kt new file mode 100644 index 0000000..452e090 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/Value.kt @@ -0,0 +1,6 @@ +package com.ivy.data + +data class Value( + val amount: Double, + val currency: CurrencyCode +) \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/account/Account.kt b/core/data-model/src/main/java/com/ivy/data/account/Account.kt new file mode 100644 index 0000000..cfb54e3 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/account/Account.kt @@ -0,0 +1,22 @@ +package com.ivy.data.account + +import androidx.annotation.ColorInt +import com.ivy.data.CurrencyCode +import com.ivy.data.ItemIconId +import com.ivy.data.Sync +import java.util.* + +@Deprecated("will be removed!") +data class Account( + val id: UUID, + val name: String, + val currency: CurrencyCode, + @ColorInt + val color: Int, + val icon: ItemIconId?, + val excluded: Boolean, + val folderId: UUID?, + val orderNum: Double, + val state: AccountState, + val sync: Sync, +) \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/account/AccountFolder.kt b/core/data-model/src/main/java/com/ivy/data/account/AccountFolder.kt new file mode 100644 index 0000000..501c795 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/account/AccountFolder.kt @@ -0,0 +1,14 @@ +package com.ivy.data.account + +import com.ivy.data.ItemIconId +import com.ivy.data.Sync + +@Deprecated("will be removed!") +data class AccountFolder( + val id: String, + val name: String, + val icon: ItemIconId?, + val color: Int, + val orderNum: Double, + val sync: Sync, +) \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/account/AccountState.kt b/core/data-model/src/main/java/com/ivy/data/account/AccountState.kt new file mode 100644 index 0000000..590580e --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/account/AccountState.kt @@ -0,0 +1,9 @@ +package com.ivy.data.account + +enum class AccountState(val code: Int) { + Default(1), Archived(2); + + companion object { + fun fromCode(code: Int): AccountState? = values().firstOrNull { it.code == code } + } +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/attachment/Attachment.kt b/core/data-model/src/main/java/com/ivy/data/attachment/Attachment.kt new file mode 100644 index 0000000..4624bc4 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/attachment/Attachment.kt @@ -0,0 +1,14 @@ +package com.ivy.data.attachment + +import com.ivy.data.Sync + +@Deprecated("will be removed!") +data class Attachment( + val id: String, + val associatedId: String, + val uri: String, + val source: AttachmentSource, + val filename: String?, + val type: AttachmentType?, + val sync: Sync, +) \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/attachment/AttachmentSource.kt b/core/data-model/src/main/java/com/ivy/data/attachment/AttachmentSource.kt new file mode 100644 index 0000000..4ed742b --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/attachment/AttachmentSource.kt @@ -0,0 +1,9 @@ +package com.ivy.data.attachment + +enum class AttachmentSource(val code: Int) { + Local(1), Remote(2); + + companion object { + fun fromCode(code: Int): AttachmentSource? = values().firstOrNull { code == it.code } + } +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/attachment/AttachmentType.kt b/core/data-model/src/main/java/com/ivy/data/attachment/AttachmentType.kt new file mode 100644 index 0000000..5d87cad --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/attachment/AttachmentType.kt @@ -0,0 +1,9 @@ +package com.ivy.data.attachment + +enum class AttachmentType(val code: Int) { + Image(1), PDF(2), File(3); + + companion object { + fun fromCode(code: Int): AttachmentType? = values().firstOrNull { it.code == code } + } +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/category/Category.kt b/core/data-model/src/main/java/com/ivy/data/category/Category.kt new file mode 100644 index 0000000..31c94c0 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/category/Category.kt @@ -0,0 +1,20 @@ +package com.ivy.data.category + +import androidx.annotation.ColorInt +import com.ivy.data.ItemIconId +import com.ivy.data.Sync +import java.util.* + +@Deprecated("will be removed!") +data class Category( + val id: UUID, + val name: String, + val type: CategoryType, + val parentCategoryId: UUID?, + @ColorInt + val color: Int, + val icon: ItemIconId?, + val orderNum: Double, + val state: CategoryState, + val sync: Sync, +) \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/category/CategoryState.kt b/core/data-model/src/main/java/com/ivy/data/category/CategoryState.kt new file mode 100644 index 0000000..36f4373 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/category/CategoryState.kt @@ -0,0 +1,9 @@ +package com.ivy.data.category + +enum class CategoryState(val code: Int) { + Default(1), Archived(2); + + companion object { + fun fromCode(code: Int): CategoryState? = values().firstOrNull { it.code == code } + } +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/category/CategoryType.kt b/core/data-model/src/main/java/com/ivy/data/category/CategoryType.kt new file mode 100644 index 0000000..5eaf5e5 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/category/CategoryType.kt @@ -0,0 +1,9 @@ +package com.ivy.data.category + +enum class CategoryType(val code: Int) { + Income(1), Expense(2), Both(3); + + companion object { + fun fromCode(code: Int) = values().firstOrNull { it.code == code } + } +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/exchange/ExchangeProvider.kt b/core/data-model/src/main/java/com/ivy/data/exchange/ExchangeProvider.kt new file mode 100644 index 0000000..07daf53 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/exchange/ExchangeProvider.kt @@ -0,0 +1,12 @@ +package com.ivy.data.exchange + +@Deprecated("will be removed!") +enum class ExchangeProvider(val code: Int) { + Old(1), + Fawazahmed0(2); + + companion object { + fun fromCode(code: Int): ExchangeProvider? = + values().firstOrNull { it.code == code } + } +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/exchange/ExchangeRates.kt b/core/data-model/src/main/java/com/ivy/data/exchange/ExchangeRates.kt new file mode 100644 index 0000000..536a268 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/exchange/ExchangeRates.kt @@ -0,0 +1,10 @@ +package com.ivy.data.exchange + +import com.ivy.data.CurrencyCode +import com.ivy.data.ExchangeRatesMap + +@Deprecated("will be removed!") +data class ExchangeRates( + val baseCurrency: CurrencyCode, + val rates: ExchangeRatesMap +) \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/file/FileType.kt b/core/data-model/src/main/java/com/ivy/data/file/FileType.kt new file mode 100644 index 0000000..2604fde --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/file/FileType.kt @@ -0,0 +1,7 @@ +package com.ivy.data.file + +enum class FileType { + Everything, + Zip, + CSV +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/tag/Tag.kt b/core/data-model/src/main/java/com/ivy/data/tag/Tag.kt new file mode 100644 index 0000000..5a80c78 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/tag/Tag.kt @@ -0,0 +1,15 @@ +package com.ivy.data.tag + +import androidx.annotation.ColorInt +import com.ivy.data.Sync + +@Deprecated("will be removed!") +data class Tag( + val id: String, + @ColorInt + val color: Int, + val name: String, + val orderNum: Double, + val state: TagState, + val sync: Sync, +) \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/tag/TagState.kt b/core/data-model/src/main/java/com/ivy/data/tag/TagState.kt new file mode 100644 index 0000000..2b7f2d3 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/tag/TagState.kt @@ -0,0 +1,9 @@ +package com.ivy.data.tag + +enum class TagState(val code: Int) { + Default(1), Archived(2); + + companion object { + fun fromCode(code: Int) = values().firstOrNull { it.code == code } + } +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/time/DynamicTimePeriod.kt b/core/data-model/src/main/java/com/ivy/data/time/DynamicTimePeriod.kt new file mode 100644 index 0000000..90e035c --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/time/DynamicTimePeriod.kt @@ -0,0 +1,22 @@ +package com.ivy.data.time + +sealed interface DynamicTimePeriod { + /** + * this week, this month, this year... + * @param offset used to offset next or last week/month/etc + */ + data class Calendar( + val unit: TimeUnit, + val offset: Int = 0 + ) : DynamicTimePeriod + + /** + * last n days/weeks/months/year + */ + data class Last(val n: Int, val unit: TimeUnit) : DynamicTimePeriod + + /** + * next n days/weeks/months/year + */ + data class Next(val n: Int, val unit: TimeUnit) : DynamicTimePeriod +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/time/Month.kt b/core/data-model/src/main/java/com/ivy/data/time/Month.kt new file mode 100644 index 0000000..c1f2fc3 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/time/Month.kt @@ -0,0 +1,6 @@ +package com.ivy.data.time + +data class Month( + val number: Int, + val year: Int, +) \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/time/SelectedPeriod.kt b/core/data-model/src/main/java/com/ivy/data/time/SelectedPeriod.kt new file mode 100644 index 0000000..bb6e99a --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/time/SelectedPeriod.kt @@ -0,0 +1,25 @@ +package com.ivy.data.time + +sealed interface SelectedPeriod { + val range: TimeRange + + data class Monthly( + val month: Month, + val startDayOfMonth: Int, + override val range: TimeRange + ) : SelectedPeriod + + data class InTheLast( + val n: Int, + val unit: TimeUnit, + override val range: TimeRange + ) : SelectedPeriod + + data class AllTime( + override val range: TimeRange + ) : SelectedPeriod + + data class CustomRange( + override val range: TimeRange + ) : SelectedPeriod +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/time/TimePeriod.kt b/core/data-model/src/main/java/com/ivy/data/time/TimePeriod.kt new file mode 100644 index 0000000..a13cb01 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/time/TimePeriod.kt @@ -0,0 +1,6 @@ +package com.ivy.data.time + +sealed interface TimePeriod { + data class Dynamic(val dynamic: DynamicTimePeriod) : TimePeriod + data class Fixed(val range: TimeRange) : TimePeriod +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/time/TimeRange.kt b/core/data-model/src/main/java/com/ivy/data/time/TimeRange.kt new file mode 100644 index 0000000..de9b505 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/time/TimeRange.kt @@ -0,0 +1,11 @@ +package com.ivy.data.time + +import androidx.compose.runtime.Immutable +import java.time.LocalDateTime + +@Deprecated("will be removed!") +@Immutable +data class TimeRange( + val from: LocalDateTime, + val to: LocalDateTime, +) \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/time/TimeUnit.kt b/core/data-model/src/main/java/com/ivy/data/time/TimeUnit.kt new file mode 100644 index 0000000..1bfc2be --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/time/TimeUnit.kt @@ -0,0 +1,5 @@ +package com.ivy.data.time + +enum class TimeUnit { + Day, Week, Month, Year; +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/transaction/Transaction.kt b/core/data-model/src/main/java/com/ivy/data/transaction/Transaction.kt new file mode 100644 index 0000000..b403674 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/transaction/Transaction.kt @@ -0,0 +1,31 @@ +package com.ivy.data.transaction + +import com.ivy.data.Sync +import com.ivy.data.Value +import com.ivy.data.account.Account +import com.ivy.data.attachment.Attachment +import com.ivy.data.category.Category +import com.ivy.data.tag.Tag +import java.util.* + +@Deprecated("will be removed!") +data class Transaction( + val id: UUID, + + val account: Account, + val type: TransactionType, + val value: Value, + val category: Category?, + val time: TrnTime, + + val title: String?, + val description: String?, + + val state: TrnState, + val purpose: TrnPurpose?, + val tags: List, + val attachments: List, + + val metadata: TrnMetadata, + val sync: Sync, +) \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/transaction/TransactionType.kt b/core/data-model/src/main/java/com/ivy/data/transaction/TransactionType.kt new file mode 100644 index 0000000..c763da9 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/transaction/TransactionType.kt @@ -0,0 +1,9 @@ +package com.ivy.data.transaction + +enum class TransactionType(val code: Int) { + Income(1), Expense(-1); + + companion object { + fun fromCode(code: Int): TransactionType? = values().firstOrNull { it.code == code } + } +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/transaction/Transfer.kt b/core/data-model/src/main/java/com/ivy/data/transaction/Transfer.kt new file mode 100644 index 0000000..0767ad8 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/transaction/Transfer.kt @@ -0,0 +1,9 @@ +package com.ivy.data.transaction + +data class Transfer( + val batchId: String, + val time: TrnTime, + val from: Transaction, + val to: Transaction, + val fee: Transaction? +) \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/transaction/TrnBatch.kt b/core/data-model/src/main/java/com/ivy/data/transaction/TrnBatch.kt new file mode 100644 index 0000000..f350ef8 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/transaction/TrnBatch.kt @@ -0,0 +1,6 @@ +package com.ivy.data.transaction + +data class TrnBatch( + val batchId: String, + val trns: List +) \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/transaction/TrnMetadata.kt b/core/data-model/src/main/java/com/ivy/data/transaction/TrnMetadata.kt new file mode 100644 index 0000000..03606d5 --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/transaction/TrnMetadata.kt @@ -0,0 +1,26 @@ +package com.ivy.data.transaction + +import java.util.* + +data class TrnMetadata( + /** + * Links transaction with a recurring rule + */ + val recurringRuleId: UUID?, + + /** + * This refers to the loan id that is linked with a transaction + */ + val loanId: UUID?, + + /** + * This refers to the loan record id that is linked with a transaction + */ + val loanRecordId: UUID?, +) { + companion object { + const val RECURRING_RULE_ID = "recurringRuleId" + const val LOAN_ID = "loanId" + const val LOAN_RECORD_ID = "loanRecordId" + } +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/transaction/TrnPurpose.kt b/core/data-model/src/main/java/com/ivy/data/transaction/TrnPurpose.kt new file mode 100644 index 0000000..7924a6a --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/transaction/TrnPurpose.kt @@ -0,0 +1,12 @@ +package com.ivy.data.transaction + +enum class TrnPurpose(val code: Int) { + TransferFrom(1), + TransferTo(2), + Fee(3), + AdjustBalance(4); + + companion object { + fun fromCode(code: Int) = values().firstOrNull { it.code == code } + } +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/transaction/TrnState.kt b/core/data-model/src/main/java/com/ivy/data/transaction/TrnState.kt new file mode 100644 index 0000000..8041dcb --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/transaction/TrnState.kt @@ -0,0 +1,12 @@ +package com.ivy.data.transaction + +const val TrnStateHidden = 2 +const val TrnStateDefault = 1 + +enum class TrnState(val code: Int) { + Default(TrnStateDefault), Hidden(TrnStateHidden); + + companion object { + fun fromCode(code: Int): TrnState? = values().firstOrNull { it.code == code } + } +} \ No newline at end of file diff --git a/core/data-model/src/main/java/com/ivy/data/transaction/TrnTime.kt b/core/data-model/src/main/java/com/ivy/data/transaction/TrnTime.kt new file mode 100644 index 0000000..01d4c4a --- /dev/null +++ b/core/data-model/src/main/java/com/ivy/data/transaction/TrnTime.kt @@ -0,0 +1,16 @@ +package com.ivy.data.transaction + +import java.time.LocalDateTime + +sealed interface TrnTime { + data class Actual(val actual: LocalDateTime) : TrnTime + data class Due(val due: LocalDateTime) : TrnTime +} + +fun dummyTrnTimeActual( + time: LocalDateTime = LocalDateTime.now() +) = TrnTime.Actual(time) + +fun dummyTrnTimeDue( + time: LocalDateTime = LocalDateTime.now().plusHours(1), +) = TrnTime.Due(time) \ No newline at end of file diff --git a/core/domain/.gitignore b/core/domain/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core/domain/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/domain/README.md b/core/domain/README.md new file mode 100644 index 0000000..3983b1d --- /dev/null +++ b/core/domain/README.md @@ -0,0 +1,5 @@ +# [Core] Domain + +Ivy Wallet's business / domain logic exposes: +- `FlowAction`'s for reading stuff as flow +- `*Act` for performing operations like saving transactions, accounts and everything that the domain requires. \ No newline at end of file diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts new file mode 100644 index 0000000..6a2c1b6 --- /dev/null +++ b/core/domain/build.gradle.kts @@ -0,0 +1,22 @@ +import com.ivy.buildsrc.ComposeTesting +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Lifecycle +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":core:persistence")) + implementation(project(":core:exchange-provider")) + + Lifecycle(api = false) + ComposeTesting(api = false) // for IdlingResource + Testing() +} \ No newline at end of file diff --git a/core/domain/src/main/AndroidManifest.xml b/core/domain/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6efac46 --- /dev/null +++ b/core/domain/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/FlowViewModel.kt b/core/domain/src/main/java/com/ivy/core/domain/FlowViewModel.kt new file mode 100644 index 0000000..f26b3b7 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/FlowViewModel.kt @@ -0,0 +1,62 @@ +package com.ivy.core.domain + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber + +abstract class FlowViewModel : ViewModel() { + private val events = MutableSharedFlow(replay = 0) + + protected abstract val initialState: InternalState + protected abstract val initialUi: UiState + + protected abstract val stateFlow: Flow + protected abstract val uiFlow: Flow + + protected abstract suspend fun handleEvent(event: Event) + + protected val state: StateFlow by lazy { + stateFlow + .flowOn(Dispatchers.Default) + .onEach { + Timber.d("Internal state = $it") + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = initialState, + ) + } + + val uiState: StateFlow by lazy { + uiFlow.onEach { + Timber.d("UI state = $it") + }.flowOn(Dispatchers.Default) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L), + initialValue = initialUi, + ) + } + + init { + viewModelScope.launch { + events.collect(::handleEvent) + } + viewModelScope.launch { + // without this delay it crashes because isn't instantiated + delay(100) + state // init the lazy val for the internal state + } + } + + fun onEvent(event: Event) { + viewModelScope.launch { + events.emit(event) + } + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/HandlerViewModel.kt b/core/domain/src/main/java/com/ivy/core/domain/HandlerViewModel.kt new file mode 100644 index 0000000..d054e77 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/HandlerViewModel.kt @@ -0,0 +1,24 @@ +package com.ivy.core.domain + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch + +abstract class HandlerViewModel : ViewModel() { + private val events = MutableSharedFlow(replay = 0) + + protected abstract suspend fun handleEvent(event: Event) + + init { + viewModelScope.launch { + events.collect(::handleEvent) + } + } + + fun onEvent(event: Event) { + viewModelScope.launch { + events.emit(event) + } + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/SimpleFlowViewModel.kt b/core/domain/src/main/java/com/ivy/core/domain/SimpleFlowViewModel.kt new file mode 100644 index 0000000..e51bce3 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/SimpleFlowViewModel.kt @@ -0,0 +1,9 @@ +package com.ivy.core.domain + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +abstract class SimpleFlowViewModel : FlowViewModel() { + override val initialState = Unit + override val stateFlow: Flow = flow {} +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/Action.kt b/core/domain/src/main/java/com/ivy/core/domain/action/Action.kt new file mode 100644 index 0000000..aabbd6d --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/Action.kt @@ -0,0 +1,15 @@ +package com.ivy.core.domain.action + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +abstract class Action { + protected abstract suspend fun action(input: Input): Output + + protected open fun dispatcher(): CoroutineDispatcher = Dispatchers.IO + + suspend operator fun invoke(input: Input): Output = withContext(dispatcher()) { + action(input) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/FlowAction.kt b/core/domain/src/main/java/com/ivy/core/domain/action/FlowAction.kt new file mode 100644 index 0000000..5789728 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/FlowAction.kt @@ -0,0 +1,28 @@ +package com.ivy.core.domain.action + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged + +/** + * Creates a flow that doesn't cache values and **executes the computation every time** + * for every terminal operator (collector). + * If you want to execute the computation only once for multiple subscribers use [SharedFlowAction]. + * + * By default the created flow will NOT emit only distinct values. + * To change that behavior override [emitDistinctValues]. + */ +abstract class FlowAction { + protected abstract fun createFlow(input: I): Flow + + /** + * @return true if you want to emit only distinct (different from the last emitted) values. + * By default will NOT emit only distinct values, to change that return true. + */ + protected open fun emitDistinctValues(): Boolean = false + + operator fun invoke(input: I): Flow { + val flow = createFlow(input) + return if (emitDistinctValues()) flow.distinctUntilChanged() else flow + } +} + diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/SharedFlowAction.kt b/core/domain/src/main/java/com/ivy/core/domain/action/SharedFlowAction.kt new file mode 100644 index 0000000..fe76561 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/SharedFlowAction.kt @@ -0,0 +1,45 @@ +package com.ivy.core.domain.action + +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn + +/** + * Creates a singleton flow which caches the last produced value. + * Use it when you want to **execute the computation only once** for multiple collectors. + * ## ATTENTION: For this to work, annotate the Action that extends it with "@Singleton". + * Don't forget to annotate your class with [javax.inject.Singleton]. + * + * By default the created flow will start eagerly (immediately). To change that behavior + * override [startType]. When the flow is started will return [initialValue]. + */ +abstract class SharedFlowAction { + private var cachedFlow: StateFlow? = null + + /** + * @return this initial value immediately after the flow is started. + */ + protected abstract fun initialValue(): T + + protected abstract fun createFlow(): Flow + + /** + * @return the mode in which the created flow will start. + * By default [SharingStarted.Eagerly] immediately after creation (even with no collectors) + * and you change that to [SharingStarted.Lazily]. + */ + open fun startType(): SharingStarted = SharingStarted.Eagerly + + operator fun invoke(): Flow = cachedFlow ?: run { + val flowInstance = createFlow() + .stateIn( + scope = MainScope(), + started = startType(), + initialValue = initialValue() + ) + cachedFlow = flowInstance + flowInstance + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/SignalFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/action/SignalFlow.kt new file mode 100644 index 0000000..7c1ca55 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/SignalFlow.kt @@ -0,0 +1,40 @@ +package com.ivy.core.domain.action + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow + +/** + * ## ATTENTION: For this to work, annotate the class that extends it with "@Singleton". + * Don't forget to annotate your class with [javax.inject.Singleton]. + */ +abstract class SignalFlow { + private val sharedFlow = MutableSharedFlow(replay = 1) + private var initialSignalSent = false + + var enabled = true + private set + + fun enable() { + enabled = true + } + + fun disable() { + enabled = false + } + + abstract fun initialSignal(): T + + suspend fun send(signal: T) { + if (enabled) { + sharedFlow.emit(signal) + } + } + + fun receive(): Flow { + if (!initialSignalSent) { + sharedFlow.tryEmit(initialSignal()) + initialSignalSent = true + } + return sharedFlow + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/account/AccountByIdAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/account/AccountByIdAct.kt new file mode 100644 index 0000000..53f287c --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/account/AccountByIdAct.kt @@ -0,0 +1,17 @@ +package com.ivy.core.domain.action.account + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.dao.account.AccountDao +import com.ivy.data.account.Account +import javax.inject.Inject + +class AccountByIdAct @Inject constructor( + private val accountDao: AccountDao, + private val timeProvider: TimeProvider +) : Action() { + override suspend fun action(accountId: String): Account? = + accountDao.findById(accountId)?.let { + toDomain(acc = it, timeProvider = timeProvider) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/account/AccountsAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/account/AccountsAct.kt new file mode 100644 index 0000000..914bb8d --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/account/AccountsAct.kt @@ -0,0 +1,16 @@ +package com.ivy.core.domain.action.account + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.dao.account.AccountDao +import com.ivy.data.account.Account +import javax.inject.Inject + +class AccountsAct @Inject constructor( + private val accountDao: AccountDao, + private val timeProvider: TimeProvider, +) : Action>() { + override suspend fun action(input: Unit): List = + accountDao.findAllOrdered() + .map { acc -> toDomain(acc = acc, timeProvider = timeProvider) } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/account/AccountsFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/action/account/AccountsFlow.kt new file mode 100644 index 0000000..946357e --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/account/AccountsFlow.kt @@ -0,0 +1,55 @@ +package com.ivy.core.domain.action.account + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.toLocal +import com.ivy.common.toUUID +import com.ivy.core.domain.action.SharedFlowAction +import com.ivy.core.persistence.dao.account.AccountDao +import com.ivy.core.persistence.entity.account.AccountEntity +import com.ivy.data.Sync +import com.ivy.data.account.Account +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** + * @return a flow of latest [Account]s by transforming db entities into domain objects. + */ +// TODO: Remove from `SharedFlowAction`: no need to be @Singleton and occupy memory! +@Singleton +class AccountsFlow @Inject constructor( + private val accountDao: AccountDao, + private val timeProvider: TimeProvider, +) : SharedFlowAction>() { + override fun initialValue(): List = emptyList() + + override fun createFlow(): Flow> = + accountDao.findAll().map { entities -> + entities.map { + toDomain(acc = it, timeProvider = timeProvider) + } + }.flowOn(Dispatchers.IO) +} + +fun toDomain( + acc: AccountEntity, + timeProvider: TimeProvider, +): Account = + Account( + id = acc.id.toUUID(), + name = acc.name, + currency = acc.currency, + color = acc.color, + icon = acc.icon, + excluded = acc.excluded, + folderId = acc.folderId?.toUUID(), + orderNum = acc.orderNum, + state = acc.state, + sync = Sync( + state = acc.sync, + lastUpdated = acc.lastUpdated.toLocal(timeProvider) + ), + ) \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/account/AdjustAccBalanceAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/account/AdjustAccBalanceAct.kt new file mode 100644 index 0000000..027efea --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/account/AdjustAccBalanceAct.kt @@ -0,0 +1,45 @@ +package com.ivy.core.domain.action.account + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.domain.action.Action +import com.ivy.core.domain.action.calculate.account.AccBalanceFlow +import com.ivy.core.domain.action.transaction.WriteTrnsAct +import com.ivy.core.domain.pure.account.adjustBalanceTrn +import com.ivy.data.account.Account +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +/** + * Adjusts [Account] balance by adding a new "adjust" transaction. The user of the API + * can choose whether this "adjust" transaction to be hidden or not. + */ +class AdjustAccBalanceAct @Inject constructor( + private val writeTrnsAct: WriteTrnsAct, + private val accBalanceFlow: AccBalanceFlow, + private val timeProvider: TimeProvider, +) : Action() { + /** + * @param hideTransaction whether to hide the adjust transactions + */ + data class Input( + val account: Account, + val desiredBalance: Double, + val hideTransaction: Boolean, + ) + + override suspend fun action(input: Input) { + val accBalance = accBalanceFlow(AccBalanceFlow.Input(account = input.account)).first() + + val adjustTrn = adjustBalanceTrn( + timeProvider = timeProvider, + account = input.account, + currentBalance = accBalance.amount, + desiredBalance = input.desiredBalance, + hiddenTrn = input.hideTransaction + ) + + if (adjustTrn != null) { + writeTrnsAct(WriteTrnsAct.Input.CreateNew(adjustTrn)) + } + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/account/NewAccountTabItemOrderNumAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/account/NewAccountTabItemOrderNumAct.kt new file mode 100644 index 0000000..d0d3e8e --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/account/NewAccountTabItemOrderNumAct.kt @@ -0,0 +1,18 @@ +package com.ivy.core.domain.action.account + +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.dao.account.AccountDao +import com.ivy.core.persistence.dao.account.AccountFolderDao +import javax.inject.Inject + +class NewAccountTabItemOrderNumAct @Inject constructor( + private val accountDao: AccountDao, + private val folderDao: AccountFolderDao, +) : Action() { + override suspend fun action(input: Unit): Double = currentMax() + 1 + + private suspend fun currentMax(): Double = maxOf(accountMax(), folderMax()) + + private suspend fun accountMax(): Double = accountDao.findMaxOrderNum() ?: 0.0 + private suspend fun folderMax(): Double = folderDao.findMaxOrderNum() ?: 0.0 +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/account/WriteAccountsAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/account/WriteAccountsAct.kt new file mode 100644 index 0000000..ae4578f --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/account/WriteAccountsAct.kt @@ -0,0 +1,71 @@ +package com.ivy.core.domain.action.account + +import arrow.core.nel +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.domain.action.Action +import com.ivy.core.domain.action.data.Modify +import com.ivy.core.domain.action.transaction.WriteTrnsAct +import com.ivy.core.domain.algorithm.accountcache.InvalidateAccCacheAct +import com.ivy.core.domain.pure.account.validateAccount +import com.ivy.core.domain.pure.mapping.entity.mapToEntity +import com.ivy.core.persistence.dao.account.AccountDao +import com.ivy.core.persistence.query.TrnQueryExecutor +import com.ivy.core.persistence.query.TrnWhere +import com.ivy.data.SyncState +import com.ivy.data.account.Account +import javax.inject.Inject + +/** + * Persists _(saves or deletes)_ accounts locally. See [Modify]. + * + * Use [Modify.save], [Modify.saveMany], [Modify.delete] or [Modify.deleteMany]. + */ +class WriteAccountsAct @Inject constructor( + private val accountDao: AccountDao, + private val writeTrnsAct: WriteTrnsAct, + private val trnQueryExecutor: TrnQueryExecutor, + private val timeProvider: TimeProvider, + private val invalidateAccCacheAct: InvalidateAccCacheAct +) : Action, Unit>() { + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(modify: Modify) { + when (modify) { + is Modify.Delete -> modify.itemIds.forEach { delete(it) } + is Modify.Save -> save(modify.items) + } + } + + private suspend fun delete(accountId: String) { + deleteTrns(accountId = accountId) + // TODO: Delete planned payments associated with that accounts + accountDao.updateSync(accountId = accountId, sync = SyncState.Deleting) + + invalidateAccCacheAct(InvalidateAccCacheAct.Input.OnDeleteAcc(accountId.nel())) + } + + private suspend fun deleteTrns(accountId: String) { + val trns = trnQueryExecutor.query(TrnWhere.ByAccountId(accountId)) + writeTrnsAct( + WriteTrnsAct.Input.ManyInefficient(trns.map { + WriteTrnsAct.Input.DeleteInefficient( + trnId = it.id + ) + }) + ) + } + + private suspend fun save(accounts: List) { + val entities = accounts.filter(::validateAccount) + .map { + it.copy( + name = it.name.trim(), + ) + } + .map { + mapToEntity(it, timeProvider = timeProvider) + .copy(sync = SyncState.Syncing) + } + accountDao.save(entities) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/account/folder/AccountFoldersFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/action/account/folder/AccountFoldersFlow.kt new file mode 100644 index 0000000..cdd7817 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/account/folder/AccountFoldersFlow.kt @@ -0,0 +1,61 @@ +package com.ivy.core.domain.action.account.folder + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.domain.action.account.AccountsFlow +import com.ivy.core.domain.action.data.AccountListItem +import com.ivy.core.domain.action.data.AccountListItem.AccountHolder +import com.ivy.core.domain.action.data.AccountListItem.FolderWithAccounts +import com.ivy.core.persistence.dao.account.AccountFolderDao +import com.ivy.core.persistence.entity.account.AccountFolderEntity +import com.ivy.data.account.Account +import com.ivy.data.account.AccountState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +private typealias FolderId = String + +class AccountFoldersFlow @Inject constructor( + private val accountsFlow: AccountsFlow, + private val accountFolderDao: AccountFolderDao, + private val timeProvider: TimeProvider, +) : FlowAction>() { + override fun createFlow(input: Unit): Flow> = combine( + accountsFlow(), accountFolderDao.findAll() + ) { accounts, folderEntities -> + val archived = AccountListItem.Archived( + accounts.filter { it.state == AccountState.Archived }.sortedBy { it.orderNum } + ).takeIf { it.accounts.isNotEmpty() } + val notArchived = accounts.filter { it.state == AccountState.Default } + + val foldersMap = notArchived.groupBy { it.folderId?.toString() ?: "none" } + val folders = folderEntities.map { toDomain(foldersMap, it) } + val accountsNotInFolder = foldersMap.filterKeys { accFolderId -> + // accounts with folder "none" aren't in any folder + if (accFolderId == "none") return@filterKeys true + val folderIds = folders.map { it.accountFolder.id } + // the referenced folder by the account doesn't exists if: + !folderIds.contains(accFolderId) + }.values.flatten() + val accountHolders = accountsNotInFolder.map(AccountListItem::AccountHolder) + + val result = if (archived != null) + folders + accountHolders + archived else folders + accountHolders + result.sortedBy { + when (it) { + is AccountHolder -> it.account.orderNum + is FolderWithAccounts -> it.accountFolder.orderNum + is AccountListItem.Archived -> Double.MAX_VALUE - 10 // put archived as last + } + } + } + + private fun toDomain( + foldersMap: Map>, + entity: AccountFolderEntity + ) = FolderWithAccounts( + accountFolder = toDomain(entity, timeProvider), + accounts = foldersMap[entity.id] ?: emptyList(), + ) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/account/folder/AccountsInFolderAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/account/folder/AccountsInFolderAct.kt new file mode 100644 index 0000000..ccd4a4d --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/account/folder/AccountsInFolderAct.kt @@ -0,0 +1,19 @@ +package com.ivy.core.domain.action.account.folder + +import com.ivy.common.toUUID +import com.ivy.core.domain.action.Action +import com.ivy.core.domain.action.account.AccountsFlow +import com.ivy.data.account.Account +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import javax.inject.Inject + +class AccountsInFolderAct @Inject constructor( + private val accountsFlow: AccountsFlow, +) : Action>() { + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(folderId: String): List { + val folderUUID = folderId.toUUID() + return accountsFlow().take(1).first().filter { it.folderId == folderUUID } + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/account/folder/FolderAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/account/folder/FolderAct.kt new file mode 100644 index 0000000..09201ec --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/account/folder/FolderAct.kt @@ -0,0 +1,35 @@ +package com.ivy.core.domain.action.account.folder + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.toLocal +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.dao.account.AccountFolderDao +import com.ivy.core.persistence.entity.account.AccountFolderEntity +import com.ivy.data.Sync +import com.ivy.data.account.AccountFolder +import javax.inject.Inject + +class FolderAct @Inject constructor( + private val accountFolderDao: AccountFolderDao, + private val timeProvider: TimeProvider, +) : Action() { + override suspend fun action(folderId: String): AccountFolder? = + accountFolderDao.findById(folderId)?.let { + toDomain(it, timeProvider) + } +} + +fun toDomain( + entity: AccountFolderEntity, + timeProvider: TimeProvider, +) = AccountFolder( + id = entity.id, + name = entity.name, + icon = entity.icon, + color = entity.color, + orderNum = entity.orderNum, + sync = Sync( + state = entity.sync, + lastUpdated = entity.lastUpdated.toLocal(timeProvider) + ), +) \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/account/folder/WriteAccountFolderAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/account/folder/WriteAccountFolderAct.kt new file mode 100644 index 0000000..4a14e85 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/account/folder/WriteAccountFolderAct.kt @@ -0,0 +1,57 @@ +package com.ivy.core.domain.action.account.folder + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.toUtc +import com.ivy.core.domain.action.Action +import com.ivy.core.domain.action.data.Modify +import com.ivy.core.persistence.dao.account.AccountFolderDao +import com.ivy.core.persistence.entity.account.AccountFolderEntity +import com.ivy.data.SyncState +import com.ivy.data.account.AccountFolder +import javax.inject.Inject + +class WriteAccountFolderAct @Inject constructor( + private val accountFolderDao: AccountFolderDao, + private val timeProvider: TimeProvider, +) : Action, Unit>() { + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(modify: Modify) = when (modify) { + is Modify.Delete -> delete(modify.itemIds) + is Modify.Save -> save(modify.items) + } + + private suspend fun delete(folderIds: List) = folderIds.forEach { + accountFolderDao.updateSync(folderId = it, sync = SyncState.Deleting) + } + + private suspend fun save(accountFolders: List) { + accountFolderDao.save( + accountFolders.filter(::validate) + .map { + it.copy(name = it.name.trim()) + } + .map { + toEntity(it, timeProvider) + } + ) + } + + private fun validate(accountFolder: AccountFolder): Boolean { + if (accountFolder.name.isBlank()) return false + return true + } + + private fun toEntity( + domain: AccountFolder, + timeProvider: TimeProvider, + ) = AccountFolderEntity( + id = domain.id, + name = domain.name, + color = domain.color, + icon = domain.icon, + orderNum = domain.orderNum, + sync = domain.sync.state, + lastUpdated = domain.sync.lastUpdated.toUtc(timeProvider) + ) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/account/folder/WriteAccountFolderContentAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/account/folder/WriteAccountFolderContentAct.kt new file mode 100644 index 0000000..d4d1f25 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/account/folder/WriteAccountFolderContentAct.kt @@ -0,0 +1,58 @@ +package com.ivy.core.domain.action.account.folder + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.toUUID +import com.ivy.core.domain.action.Action +import com.ivy.core.domain.action.account.AccountsFlow +import com.ivy.core.domain.action.account.WriteAccountsAct +import com.ivy.core.domain.action.account.folder.WriteAccountFolderContentAct.Input +import com.ivy.core.domain.action.data.Modify +import com.ivy.data.Sync +import com.ivy.data.SyncState +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import javax.inject.Inject + +class WriteAccountFolderContentAct @Inject constructor( + private val writeAccountsAct: WriteAccountsAct, + private val accountsFlow: AccountsFlow, + private val timeProvider: TimeProvider, +) : Action() { + data class Input( + val folderId: String, + val accountIds: List + ) + + override suspend fun action(input: Input) { + val folderUUID = input.folderId.toUUID() + val accounts = accountsFlow().take(1).first() + val inFolderOld = accounts.filter { it.folderId == folderUUID } + + // remove accounts no longer in folder + val removeFromFolder = inFolderOld.filter { !input.accountIds.contains(it.id.toString()) } + .map { + it.copy( + folderId = null, + sync = Sync( + state = SyncState.Syncing, + lastUpdated = timeProvider.timeNow(), + ) + ) + } + writeAccountsAct(Modify.saveMany(removeFromFolder)) + + // add new accounts to that folder + val addToFolder = accounts.filter { input.accountIds.contains(it.id.toString()) } + .filter { !inFolderOld.contains(it) } + .map { + it.copy( + folderId = folderUUID, + sync = Sync( + state = SyncState.Syncing, + lastUpdated = timeProvider.timeNow(), + ) + ) + } + writeAccountsAct(Modify.saveMany(addToFolder)) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/calculate/CalculateFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/action/calculate/CalculateFlow.kt new file mode 100644 index 0000000..03d48ad --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/calculate/CalculateFlow.kt @@ -0,0 +1,125 @@ +package com.ivy.core.domain.action.calculate + +import arrow.core.getOrElse +import arrow.core.nonEmptyListOf +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.domain.action.exchange.ExchangeRatesFlow +import com.ivy.core.domain.pure.calculate.filter +import com.ivy.core.domain.pure.exchange.exchange +import com.ivy.core.domain.pure.transaction.sumTransactions +import com.ivy.data.CurrencyCode +import com.ivy.data.Value +import com.ivy.data.exchange.ExchangeRates +import com.ivy.data.transaction.Transaction +import com.ivy.data.transaction.TransactionType +import com.ivy.data.transaction.TrnPurpose.TransferFrom +import com.ivy.data.transaction.TrnPurpose.TransferTo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +/** + * Calculates [Stats] (income, expense, counts, balance) for a list of [Transaction] + * converted in a **outputCurrency** of your choice. + */ +class CalculateFlow @Inject constructor( + private val exchangeRatesFlow: ExchangeRatesFlow, +) : FlowAction() { + /** + * @param trns transactions for which the stats will be calculated. + * Transfers may be excluded depending on [includeTransfers]. + * @param includeTransfers whether to include transfer transactions in the calculation. + * - **false** to exclude transactions with purpose [TransferFrom] and [TransferTo] + * - **true** to include all [trns] in the calculation + * @param includeHidden whether to include hidde transactions in the calculation. + * @param outputCurrency pass **null** for base currency. + */ + data class Input( + val trns: List, + val includeTransfers: Boolean, + val includeHidden: Boolean, + val outputCurrency: CurrencyCode? = null, + ) + + override fun createFlow(input: Input): Flow = exchangeRatesFlow().map { rates -> + input.calculate(rates = rates) + } + + private suspend fun Input.calculate( + rates: ExchangeRates, + ): Stats { + val outputCurrency = this.outputCurrency ?: rates.baseCurrency + val res = sumTransactions( + transactions = trns.filter( + includeTransfers = includeTransfers, + includeHidden = includeHidden, + ), + selectors = nonEmptyListOf( + ::income, + ::expense, + ::countIncome, + ::countExpense, + ), + arg = SumArg( + rates = rates, + outputCurrency = outputCurrency, + ) + ) + + val income = res[0] + val expense = res[1] + + return Stats( + balance = Value(amount = income - expense, currency = outputCurrency), + income = Value(amount = income, currency = outputCurrency), + expense = Value(amount = expense, currency = outputCurrency), + incomesCount = res[2].toInt(), + expensesCount = res[3].toInt(), + ) + } + + private suspend fun income( + trn: Transaction, arg: SumArg + ): Double = when (trn.type) { + TransactionType.Income -> trnAmountInCurrency(trn, arg) + else -> 0.0 + } + + private suspend fun expense( + trn: Transaction, arg: SumArg + ): Double = when (trn.type) { + TransactionType.Expense -> trnAmountInCurrency(trn, arg) + else -> 0.0 + } + + private suspend fun trnAmountInCurrency( + trn: Transaction, + arg: SumArg, + ): Double = exchange( + exchangeData = arg.rates, + from = trn.value.currency, + to = arg.outputCurrency, + amount = trn.value.amount, + ).getOrElse { 0.0 } + + @Suppress("RedundantSuspendModifier", "UNUSED_PARAMETER") + private suspend fun countIncome( + trn: Transaction, arg: SumArg + ): Double = when (trn.type) { + TransactionType.Income -> 1.0 + else -> 0.0 + } + + @Suppress("RedundantSuspendModifier", "UNUSED_PARAMETER") + private suspend fun countExpense( + trn: Transaction, arg: SumArg + ): Double = when (trn.type) { + TransactionType.Expense -> 1.0 + else -> 0.0 + } + + private data class SumArg( + val rates: ExchangeRates, + val outputCurrency: CurrencyCode, + ) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/calculate/Stats.kt b/core/domain/src/main/java/com/ivy/core/domain/action/calculate/Stats.kt new file mode 100644 index 0000000..322c6a9 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/calculate/Stats.kt @@ -0,0 +1,21 @@ +package com.ivy.core.domain.action.calculate + +import com.ivy.data.Value + +/** + * Data type representing [CalculateFlow]'s result. + * @param balance it's equal to [income] - [expense] + * @param income the sum of income transactions + * @param expense the sum of expense transactions + * @param incomesCount the # of income transactions or simply how many incomes there were as a count + * @param expensesCount the # of expense transactions or + * simply how many expenses there were as a count + */ +@Deprecated("inefficient - will be replaced with `account-cache` algo") +data class Stats( + val balance: Value, + val income: Value, + val expense: Value, + val incomesCount: Int, + val expensesCount: Int, +) \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/calculate/account/AccBalanceFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/action/calculate/account/AccBalanceFlow.kt new file mode 100644 index 0000000..255097e --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/calculate/account/AccBalanceFlow.kt @@ -0,0 +1,36 @@ +package com.ivy.core.domain.action.calculate.account + +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.domain.pure.time.allTime +import com.ivy.data.CurrencyCode +import com.ivy.data.Value +import com.ivy.data.account.Account +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +/** + * Calculates account's balance. Including hidden and transfer transactions. + */ +@Deprecated("inefficient - will be replaced with `account-cache` algo") +class AccBalanceFlow @Inject constructor( + private val accStatsFlow: AccStatsFlow, +) : FlowAction() { + + @Deprecated("inefficient - will be replaced with `account-cache` algo") + data class Input( + val account: Account, + val outputCurrency: CurrencyCode = account.currency, + ) + + override fun createFlow(input: Input): Flow = accStatsFlow( + AccStatsFlow.Input( + account = input.account, + range = allTime(), + includeHidden = true, + outputCurrency = input.outputCurrency + ) + ).map { stats -> + stats.balance + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/calculate/account/AccStatsFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/action/calculate/account/AccStatsFlow.kt new file mode 100644 index 0000000..77a90dc --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/calculate/account/AccStatsFlow.kt @@ -0,0 +1,52 @@ +package com.ivy.core.domain.action.calculate.account + +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.domain.action.calculate.CalculateFlow +import com.ivy.core.domain.action.calculate.Stats +import com.ivy.core.domain.action.transaction.TrnQuery.ActualBetween +import com.ivy.core.domain.action.transaction.TrnQuery.ByAccountId +import com.ivy.core.domain.action.transaction.TrnsFlow +import com.ivy.core.domain.action.transaction.and +import com.ivy.data.CurrencyCode +import com.ivy.data.account.Account +import com.ivy.data.time.TimeRange +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import javax.inject.Inject + +/** + * Calculates account's incomes and expenses **including transfer transactions**. + * The inclusion of hidden transactions is chosen by the user of this API. + */ +@Deprecated("inefficient - will be replaced with `account-cache` algo") +class AccStatsFlow @Inject constructor( + private val trnsFlow: TrnsFlow, + private val calculateFlow: CalculateFlow, +) : FlowAction() { + @Deprecated("inefficient - will be replaced with `account-cache` algo") + /** + * @param outputCurrency the desired currency of the result, **defaults to account's currency** + */ + data class Input( + val account: Account, + val range: TimeRange, + val includeHidden: Boolean, + val outputCurrency: CurrencyCode = account.currency, + ) + + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + override fun createFlow(input: Input): Flow = + trnsFlow(ByAccountId(input.account.id) and ActualBetween(input.range)) + .flatMapLatest { trns -> + calculateFlow( + CalculateFlow.Input( + trns = trns, + outputCurrency = input.outputCurrency, + includeHidden = input.includeHidden, + includeTransfers = true + ) + ) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/calculate/category/CatStatsFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/action/calculate/category/CatStatsFlow.kt new file mode 100644 index 0000000..7818745 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/calculate/category/CatStatsFlow.kt @@ -0,0 +1,47 @@ +package com.ivy.core.domain.action.calculate.category + +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.domain.action.calculate.CalculateFlow +import com.ivy.core.domain.action.calculate.Stats +import com.ivy.core.domain.action.transaction.TrnQuery.ActualBetween +import com.ivy.core.domain.action.transaction.TrnQuery.ByCategoryId +import com.ivy.core.domain.action.transaction.TrnsFlow +import com.ivy.core.domain.action.transaction.and +import com.ivy.data.CurrencyCode +import com.ivy.data.category.Category +import com.ivy.data.time.TimeRange +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import javax.inject.Inject + +/** + * Calculates category's incomes and expenses **excluding transfers and hidden transactions**. + */ +class CatStatsFlow @Inject constructor( + private val trnsFlow: TrnsFlow, + private val calculateFlow: CalculateFlow, +) : FlowAction() { + /** + * @param outputCurrency pass **null** for base currency + */ + data class Input( + val range: TimeRange, + val category: Category?, + val outputCurrency: CurrencyCode? = null, + ) + + @OptIn(FlowPreview::class) + override fun createFlow(input: Input): Flow = trnsFlow( + ByCategoryId(categoryId = input.category?.id) and ActualBetween(input.range) + ).flatMapLatest { trns -> + calculateFlow( + CalculateFlow.Input( + trns = trns, + outputCurrency = null, + includeTransfers = false, + includeHidden = false, + ) + ) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/category/CategoriesFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/action/category/CategoriesFlow.kt new file mode 100644 index 0000000..05b1749 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/category/CategoriesFlow.kt @@ -0,0 +1,50 @@ +package com.ivy.core.domain.action.category + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.toLocal +import com.ivy.common.toUUID +import com.ivy.core.domain.action.SharedFlowAction +import com.ivy.core.persistence.dao.category.CategoryDao +import com.ivy.core.persistence.entity.category.CategoryEntity +import com.ivy.data.Sync +import com.ivy.data.category.Category +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** + * @return a flow of latest [Category]ies by transforming db entities into domain objects. + */ +@Singleton +class CategoriesFlow @Inject constructor( + private val categoryDao: CategoryDao, + private val timeProvider: TimeProvider, +) : SharedFlowAction>() { + override fun initialValue(): List = emptyList() + + override fun createFlow(): Flow> = + categoryDao.findAll().map { entities -> + entities.map { toDomain(it, timeProvider) } + }.flowOn(Dispatchers.Default) +} + +fun toDomain( + it: CategoryEntity, + timeProvider: TimeProvider, +) = Category( + id = it.id.toUUID(), + name = it.name, + parentCategoryId = it.parentCategoryId?.toUUID(), + color = it.color, + icon = it.icon, + orderNum = it.orderNum, + sync = Sync( + state = it.sync, + lastUpdated = it.lastUpdated.toLocal(timeProvider) + ), + type = it.type, + state = it.state, +) \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/category/CategoriesListFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/action/category/CategoriesListFlow.kt new file mode 100644 index 0000000..e5cdced --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/category/CategoriesListFlow.kt @@ -0,0 +1,90 @@ +package com.ivy.core.domain.action.category + +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.domain.action.data.CategoryListItem +import com.ivy.data.category.Category +import com.ivy.data.category.CategoryState +import com.ivy.data.category.CategoryType +import com.ivy.data.transaction.TransactionType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.util.* +import javax.inject.Inject + +class CategoriesListFlow @Inject constructor( + private val categoriesFlow: CategoriesFlow, +) : FlowAction>() { + /** + * @param trnType - null for all categories + */ + data class Input( + val trnType: TransactionType?, + ) + + override fun createFlow(input: Input): Flow> = + categoriesFlow() + // Filter only categories that match the selected transaction type + .map { categories -> + if (input.trnType != null) { + categories.filter { + when (it.type) { + CategoryType.Income -> input.trnType == TransactionType.Income + CategoryType.Expense -> input.trnType == TransactionType.Expense + CategoryType.Both -> true + } + } + } else categories + } + .map { categories -> + val archived = mutableListOf() + val parents = mutableListOf() + val subcategories = mutableMapOf>() + + categories.forEach { + if (it.state == CategoryState.Archived) { + archived.add(it) + return@forEach + } + val parentCategoryId = it.parentCategoryId + if (parentCategoryId == null) { + parents.add(it) + } else { + subcategories.computeIfAbsent(parentCategoryId) { + mutableListOf() + } + subcategories[parentCategoryId]!!.add(it) + } + } + + val notArchived = parents.map { parent -> + val children = subcategories[parent.id]?.takeIf { it.isNotEmpty() } + subcategories.remove(parent.id) + + if (children != null) { + CategoryListItem.ParentCategory( + parent = parent, + children = children.sortedBy { it.orderNum } + ) + } else { + CategoryListItem.CategoryHolder( + parent + ) + } + } + subcategories.values.flatten().map { + CategoryListItem.CategoryHolder(it) + } + + val allItems = if (archived.isNotEmpty()) + notArchived + CategoryListItem.Archived( + archived.sortedBy { it.orderNum } + ) else notArchived + + allItems.sortedBy { + when (it) { + is CategoryListItem.Archived -> Double.MAX_VALUE - 10 + is CategoryListItem.CategoryHolder -> it.category.orderNum + is CategoryListItem.ParentCategory -> it.parent.orderNum + } + } + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/category/CategoryByIdAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/category/CategoryByIdAct.kt new file mode 100644 index 0000000..73301e6 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/category/CategoryByIdAct.kt @@ -0,0 +1,19 @@ +package com.ivy.core.domain.action.category + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.dao.category.CategoryDao +import com.ivy.data.category.Category +import javax.inject.Inject + +class CategoryByIdAct @Inject constructor( + private val categoryDao: CategoryDao, + private val timeProvider: TimeProvider, +) : Action() { + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(categoryId: String): Category? = + categoryDao.findById(categoryId)?.let { + toDomain(it, timeProvider) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/category/NewCategoryOrderNumAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/category/NewCategoryOrderNumAct.kt new file mode 100644 index 0000000..4e6a3e1 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/category/NewCategoryOrderNumAct.kt @@ -0,0 +1,12 @@ +package com.ivy.core.domain.action.category + +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.dao.category.CategoryDao +import javax.inject.Inject + +class NewCategoryOrderNumAct @Inject constructor( + private val categoryDao: CategoryDao, +) : Action() { + override suspend fun action(input: Unit): Double = + categoryDao.findMaxNoParentOrderNum()?.plus(1) ?: 0.0 +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/category/WriteCategoriesAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/category/WriteCategoriesAct.kt new file mode 100644 index 0000000..7dfd5bf --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/category/WriteCategoriesAct.kt @@ -0,0 +1,49 @@ +package com.ivy.core.domain.action.category + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.domain.action.Action +import com.ivy.core.domain.action.data.Modify +import com.ivy.core.domain.pure.mapping.entity.mapToEntity +import com.ivy.core.persistence.dao.category.CategoryDao +import com.ivy.data.SyncState +import com.ivy.data.category.Category +import javax.inject.Inject + +/** + * Persists _(saves or deletes)_ categories locally. See [Modify]. + * + * Use [Modify.save], [Modify.saveMany], [Modify.delete] or [Modify.deleteMany]. + */ +class WriteCategoriesAct @Inject constructor( + private val categoryDao: CategoryDao, + private val timeProvider: TimeProvider, +) : Action, Unit>() { + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(modify: Modify) { + when (modify) { + is Modify.Delete -> delete(modify.itemIds) + is Modify.Save -> save(modify.items) + } + } + + private suspend fun delete(categoryIds: List) { + categoryDao.updateSync( + categoryIds = categoryIds, + sync = SyncState.Deleting + ) + } + + private suspend fun save(categories: List) { + categoryDao.save( + categories + .filter { it.name.isNotBlank() } + .map { it.copy(name = it.name.trim()) } + .map { + mapToEntity(it, timeProvider = timeProvider) + .copy(sync = SyncState.Syncing) + } + ) + } + +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/data/AccountListItem.kt b/core/domain/src/main/java/com/ivy/core/domain/action/data/AccountListItem.kt new file mode 100644 index 0000000..8be1063 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/data/AccountListItem.kt @@ -0,0 +1,14 @@ +package com.ivy.core.domain.action.data + +import com.ivy.data.account.Account +import com.ivy.data.account.AccountFolder + +sealed interface AccountListItem { + data class AccountHolder(val account: Account) : AccountListItem + data class FolderWithAccounts( + val accountFolder: AccountFolder, + val accounts: List, + ) : AccountListItem + + data class Archived(val accounts: List) : AccountListItem +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/data/CategoryListItem.kt b/core/domain/src/main/java/com/ivy/core/domain/action/data/CategoryListItem.kt new file mode 100644 index 0000000..365ff6c --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/data/CategoryListItem.kt @@ -0,0 +1,18 @@ +package com.ivy.core.domain.action.data + +import com.ivy.data.category.Category + +sealed interface CategoryListItem { + data class CategoryHolder( + val category: Category, + ) : CategoryListItem + + data class ParentCategory( + val parent: Category, + val children: List, + ) : CategoryListItem + + data class Archived( + val categories: List, + ) : CategoryListItem +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/data/Modify.kt b/core/domain/src/main/java/com/ivy/core/domain/action/data/Modify.kt new file mode 100644 index 0000000..eb2d084 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/data/Modify.kt @@ -0,0 +1,47 @@ +package com.ivy.core.domain.action.data + +/** + * Data type representing a save or delete operation. + * Modify operations are usually used for "WriteAct"s like + * [com.ivy.core.domain.action.transaction.WriteTrnsAct], + * [com.ivy.core.domain.action.account.WriteAccountsAct], + * [com.ivy.core.domain.action.category.WriteCategoriesAct]. + * + * It supports: + * - [save]: persists a single item. + * - [saveMany]: persists multiple items. + * - [delete]: deletes a single item by id. + * - [deleteMany]: deletes multiple items by their ids. + */ +sealed interface Modify { + companion object { + /** + * @param item the item to persist. + * @return an operation for persisting a single item. + */ + fun save(item: T) = Save(listOf(item)) + + /** + * @param items a collection of items to persist, + * will be converted to [List] under the hood. + * @return an operation for persisting multiple items. + */ + fun saveMany(items: Iterable) = Save(items.toList()) + + /** + * @param itemId the string id of the item to be deleted. + * @return an operation to delete a single item + */ + fun delete(itemId: String) = Delete(listOf(itemId)) + + /** + * @param itemIds collection of the string ids of the items to be deleted, + * will be converted to [List] under the hood. + * @return an operation to delete multiple items. + */ + fun deleteMany(itemIds: Iterable) = Delete(itemIds.toList()) + } + + data class Save internal constructor(val items: List) : Modify + data class Delete internal constructor(val itemIds: List) : Modify +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/exchange/ExchangeAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/exchange/ExchangeAct.kt new file mode 100644 index 0000000..7634f6f --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/exchange/ExchangeAct.kt @@ -0,0 +1,32 @@ +package com.ivy.core.domain.action.exchange + +import arrow.core.getOrElse +import com.ivy.core.domain.action.Action +import com.ivy.core.domain.pure.exchange.exchange +import com.ivy.data.Value +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +class ExchangeAct @Inject constructor( + private val exchangeRatesFlow: ExchangeRatesFlow, +) : Action() { + data class Input( + val value: Value, + val outputCurrency: String, + ) + + override suspend fun action(input: Input): Value { + val rates = exchangeRatesFlow().first() + return Value( + amount = exchange( + exchangeData = rates, + from = input.value.currency, + to = input.outputCurrency, + amount = input.value.amount + ).getOrElse { + input.value.amount // exchange as 1:1 if failed + }, + currency = input.outputCurrency + ) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/exchange/ExchangeFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/action/exchange/ExchangeFlow.kt new file mode 100644 index 0000000..96efce1 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/exchange/ExchangeFlow.kt @@ -0,0 +1,35 @@ +package com.ivy.core.domain.action.exchange + +import arrow.core.getOrElse +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.domain.action.exchange.ExchangeFlow.Input +import com.ivy.core.domain.pure.exchange.exchange +import com.ivy.data.CurrencyCode +import com.ivy.data.Value +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class ExchangeFlow @Inject constructor( + private val exchangeRatesFlow: ExchangeRatesFlow +) : FlowAction() { + /** + * @param outputCurrency null for baseCurrency + */ + data class Input( + val value: Value, + val outputCurrency: CurrencyCode? = null, + ) + + override fun createFlow(input: Input): Flow = + exchangeRatesFlow().map { rates -> + val outputCurrency = input.outputCurrency ?: rates.baseCurrency + val exchangedAmount = exchange( + exchangeData = rates, + from = input.value.currency, + to = outputCurrency, + amount = input.value.amount + ).getOrElse { 0.0 } + Value(exchangedAmount, outputCurrency) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/exchange/ExchangeRatesFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/action/exchange/ExchangeRatesFlow.kt new file mode 100644 index 0000000..8fce01a --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/exchange/ExchangeRatesFlow.kt @@ -0,0 +1,59 @@ +package com.ivy.core.domain.action.exchange + +import com.ivy.core.domain.action.SharedFlowAction +import com.ivy.core.domain.action.settings.basecurrency.BaseCurrencyFlow +import com.ivy.core.persistence.dao.exchange.ExchangeRateDao +import com.ivy.core.persistence.dao.exchange.ExchangeRateOverrideDao +import com.ivy.data.exchange.ExchangeRates +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import javax.inject.Inject +import javax.inject.Singleton + +/** + * @return [ExchangeRates], the latest exchange rates and base currency, + * considering manually overridden rates. + * + * _Note: Initially emits empty base currency and rates. In most cases that won't happen + * because this is a [SharedFlowAction] and it might be already initialized._ + */ +@Singleton +class ExchangeRatesFlow @Inject constructor( + private val baseCurrencyFlow: BaseCurrencyFlow, + private val exchangeRateDao: ExchangeRateDao, + private val exchangeRateOverrideDao: ExchangeRateOverrideDao, +) : SharedFlowAction() { + override fun initialValue(): ExchangeRates = ExchangeRates( + baseCurrency = "", + rates = emptyMap() + ) + + @OptIn(FlowPreview::class) + override fun createFlow(): Flow = + baseCurrencyFlow().flatMapLatest { baseCurrency -> + combine( + exchangeRateDao.findAllByBaseCurrency(baseCurrency), + exchangeRateOverrideDao.findAllByBaseCurrency(baseCurrency) + ) { rateEntities, ratesOverride -> + val ratesMap = rateEntities + .filter { it.baseCurrency == baseCurrency } + .associate { it.currency to it.rate } + .toMutableMap() + + ratesOverride.filter { it.baseCurrency == baseCurrency } + .onEach { + // override automatic rates by manually set ones + ratesMap[it.currency] = it.rate + } + + ExchangeRates( + baseCurrency = baseCurrency, + rates = ratesMap, + ) + } + }.flowOn(Dispatchers.Default) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/exchange/SumValuesInCurrencyFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/action/exchange/SumValuesInCurrencyFlow.kt new file mode 100644 index 0000000..68ef686 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/exchange/SumValuesInCurrencyFlow.kt @@ -0,0 +1,41 @@ +package com.ivy.core.domain.action.exchange + +import arrow.core.getOrElse +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.domain.action.exchange.SumValuesInCurrencyFlow.Input +import com.ivy.core.domain.pure.exchange.exchange +import com.ivy.data.CurrencyCode +import com.ivy.data.Value +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class SumValuesInCurrencyFlow @Inject constructor( + private val exchangeRatesFlow: ExchangeRatesFlow +) : FlowAction() { + /** + * @param outputCurrency null for base currency + */ + data class Input( + val values: List, + val outputCurrency: CurrencyCode? = null, + ) + + override fun createFlow(input: Input): Flow = + exchangeRatesFlow().map { rates -> + val outputCurrency = input.outputCurrency ?: rates.baseCurrency + val sum = input.values.sumOf { + exchange( + exchangeData = rates, + from = it.currency, to = outputCurrency, + amount = it.amount + ).getOrElse { 0.0 } + } + Value( + amount = sum, + currency = outputCurrency + ) + }.flowOn(Dispatchers.Default) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/exchange/SyncExchangeRatesAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/exchange/SyncExchangeRatesAct.kt new file mode 100644 index 0000000..f28b667 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/exchange/SyncExchangeRatesAct.kt @@ -0,0 +1,39 @@ +package com.ivy.core.domain.action.exchange + +import com.ivy.common.isNotBlank +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.dao.exchange.ExchangeRateDao +import com.ivy.core.persistence.entity.exchange.ExchangeRateEntity +import com.ivy.data.CurrencyCode +import com.ivy.exchange.RemoteExchangeProvider +import javax.inject.Inject + +class SyncExchangeRatesAct @Inject constructor( + private val exchangeProvider: RemoteExchangeProvider, + private val exchangeRateDao: ExchangeRateDao +) : Action() { + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(baseCurrency: CurrencyCode) { + if (baseCurrency.isNotBlank()) { + syncExchangeRates(baseCurrency) + } + } + + private suspend fun syncExchangeRates(baseCurrency: CurrencyCode) { + val result = exchangeProvider.fetchExchangeRates(baseCurrency = baseCurrency) + exchangeRateDao.save( + result.ratesMap.mapNotNull { (currency, rate) -> + if (rate > 0.0) { + ExchangeRateEntity( + baseCurrency = baseCurrency.uppercase(), + currency = currency.uppercase(), + rate = rate, + provider = result.provider + ) + } else null + } + ) + } +} + diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/period/SelectedPeriodFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/action/period/SelectedPeriodFlow.kt new file mode 100644 index 0000000..0e5e761 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/period/SelectedPeriodFlow.kt @@ -0,0 +1,38 @@ +package com.ivy.core.domain.action.period + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.domain.action.SharedFlowAction +import com.ivy.core.domain.action.settings.startdayofmonth.StartDayOfMonthFlow +import com.ivy.core.domain.pure.time.currentMonthlyPeriod +import com.ivy.core.domain.pure.time.monthlyPeriod +import com.ivy.data.time.SelectedPeriod +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import javax.inject.Inject +import javax.inject.Singleton + +/** + * @return a flow of the currently selected period [SelectedPeriod]. + */ +@Singleton +class SelectedPeriodFlow @Inject constructor( + private val startDayOfMonthFlow: StartDayOfMonthFlow, + private val selectedPeriodSignal: SelectedPeriodSignal, + private val timeProvider: TimeProvider, +) : SharedFlowAction() { + override fun initialValue(): SelectedPeriod = + currentMonthlyPeriod(startDayOfMonth = 1, timeProvider = timeProvider) + + override fun createFlow(): Flow = combine( + startDayOfMonthFlow(), selectedPeriodSignal.receive() + ) { startDayOfMonth, selectedPeriod -> + if (selectedPeriod is SelectedPeriod.Monthly) { + monthlyPeriod( + dateInPeriod = selectedPeriod.range.to.minusDays(2).toLocalDate(), + startDayOfMonth = startDayOfMonth + ) + } else selectedPeriod + }.flowOn(Dispatchers.Default) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/period/SelectedPeriodSignal.kt b/core/domain/src/main/java/com/ivy/core/domain/action/period/SelectedPeriodSignal.kt new file mode 100644 index 0000000..f68c423 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/period/SelectedPeriodSignal.kt @@ -0,0 +1,20 @@ +package com.ivy.core.domain.action.period + +import android.content.Context +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.domain.action.SignalFlow +import com.ivy.core.domain.pure.time.currentMonthlyPeriod +import com.ivy.data.time.SelectedPeriod +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SelectedPeriodSignal @Inject constructor( + @ApplicationContext + private val appContext: Context, + private val timeProvider: TimeProvider +) : SignalFlow() { + override fun initialSignal(): SelectedPeriod = + currentMonthlyPeriod(startDayOfMonth = 1, timeProvider = timeProvider) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/period/SetSelectedPeriodAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/period/SetSelectedPeriodAct.kt new file mode 100644 index 0000000..94765d5 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/period/SetSelectedPeriodAct.kt @@ -0,0 +1,17 @@ +package com.ivy.core.domain.action.period + +import com.ivy.core.domain.action.Action +import com.ivy.data.time.SelectedPeriod +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import javax.inject.Inject + +class SetSelectedPeriodAct @Inject constructor( + private val selectedPeriodSignal: SelectedPeriodSignal +) : Action() { + override fun dispatcher(): CoroutineDispatcher = Dispatchers.Unconfined + + override suspend fun action(input: SelectedPeriod) { + selectedPeriodSignal.send(input) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/settings/applocked/AppLockedFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/action/settings/applocked/AppLockedFlow.kt new file mode 100644 index 0000000..590f063 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/settings/applocked/AppLockedFlow.kt @@ -0,0 +1,17 @@ +package com.ivy.core.domain.action.settings.applocked + +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.persistence.datastore.IvyDataStore +import com.ivy.core.persistence.datastore.keys.SettingsKeys +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class AppLockedFlow @Inject constructor( + private val dataStore: IvyDataStore, + private val settingsKeys: SettingsKeys +) : FlowAction() { + override fun createFlow(input: Unit): Flow = + dataStore.get(settingsKeys.appLocked) + .map { it ?: false } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/settings/applocked/WriteAppLockedAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/settings/applocked/WriteAppLockedAct.kt new file mode 100644 index 0000000..dacf5ad --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/settings/applocked/WriteAppLockedAct.kt @@ -0,0 +1,17 @@ +package com.ivy.core.domain.action.settings.applocked + +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.datastore.IvyDataStore +import com.ivy.core.persistence.datastore.keys.SettingsKeys +import javax.inject.Inject + +class WriteAppLockedAct @Inject constructor( + private val dataStore: IvyDataStore, + private val settingsKeys: SettingsKeys +) : Action() { + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(appLocked: Boolean) { + dataStore.put(key = settingsKeys.appLocked, appLocked) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/settings/balance/HideBalanceFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/action/settings/balance/HideBalanceFlow.kt new file mode 100644 index 0000000..f7ba3a3 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/settings/balance/HideBalanceFlow.kt @@ -0,0 +1,17 @@ +package com.ivy.core.domain.action.settings.balance + +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.persistence.datastore.IvyDataStore +import com.ivy.core.persistence.datastore.keys.SettingsKeys +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class HideBalanceFlow @Inject constructor( + private val dataStore: IvyDataStore, + private val settingsKeys: SettingsKeys +) : FlowAction() { + override fun createFlow(input: Unit): Flow = + dataStore.get(settingsKeys.hideBalance) + .map { it ?: false } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/settings/balance/WriteHideBalanceAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/settings/balance/WriteHideBalanceAct.kt new file mode 100644 index 0000000..8ba982e --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/settings/balance/WriteHideBalanceAct.kt @@ -0,0 +1,17 @@ +package com.ivy.core.domain.action.settings.balance + +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.datastore.IvyDataStore +import com.ivy.core.persistence.datastore.keys.SettingsKeys +import javax.inject.Inject + +class WriteHideBalanceAct @Inject constructor( + private val dataStore: IvyDataStore, + private val settingsKeys: SettingsKeys +) : Action() { + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(hideBalance: Boolean) { + dataStore.put(key = settingsKeys.hideBalance, hideBalance) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/settings/basecurrency/BaseCurrencyAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/settings/basecurrency/BaseCurrencyAct.kt new file mode 100644 index 0000000..8794144 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/settings/basecurrency/BaseCurrencyAct.kt @@ -0,0 +1,17 @@ +package com.ivy.core.domain.action.settings.basecurrency + +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.datastore.IvyDataStore +import com.ivy.core.persistence.datastore.keys.SettingsKeys +import com.ivy.data.CurrencyCode +import kotlinx.coroutines.flow.firstOrNull +import javax.inject.Inject + +class BaseCurrencyAct @Inject constructor( + private val dataStore: IvyDataStore, + private val settingsKeys: SettingsKeys, +) : Action() { + + override suspend fun action(input: Unit): CurrencyCode = + dataStore.get(settingsKeys.baseCurrency).firstOrNull() ?: "" +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/settings/basecurrency/BaseCurrencyFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/action/settings/basecurrency/BaseCurrencyFlow.kt new file mode 100644 index 0000000..ba82126 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/settings/basecurrency/BaseCurrencyFlow.kt @@ -0,0 +1,21 @@ +package com.ivy.core.domain.action.settings.basecurrency + +import com.ivy.core.domain.action.SharedFlowAction +import com.ivy.core.persistence.datastore.IvyDataStore +import com.ivy.core.persistence.datastore.keys.SettingsKeys +import com.ivy.data.CurrencyCode +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BaseCurrencyFlow @Inject constructor( + private val dataStore: IvyDataStore, + private val settingsKeys: SettingsKeys, +) : SharedFlowAction() { + override fun initialValue(): CurrencyCode = "" + + override fun createFlow(): Flow = + dataStore.get(settingsKeys.baseCurrency).filterNotNull() +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/settings/basecurrency/WriteBaseCurrencyAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/settings/basecurrency/WriteBaseCurrencyAct.kt new file mode 100644 index 0000000..241675e --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/settings/basecurrency/WriteBaseCurrencyAct.kt @@ -0,0 +1,17 @@ +package com.ivy.core.domain.action.settings.basecurrency + +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.datastore.IvyDataStore +import com.ivy.core.persistence.datastore.keys.SettingsKeys +import javax.inject.Inject + +class WriteBaseCurrencyAct @Inject constructor( + private val dataStore: IvyDataStore, + private val settingsKeys: SettingsKeys +) : Action() { + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(baseCurrency: String) { + dataStore.put(key = settingsKeys.baseCurrency, value = baseCurrency) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/settings/name/NameFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/action/settings/name/NameFlow.kt new file mode 100644 index 0000000..4a5b0cb --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/settings/name/NameFlow.kt @@ -0,0 +1,16 @@ +package com.ivy.core.domain.action.settings.name + +import com.ivy.core.persistence.datastore.IvyDataStore +import com.ivy.core.persistence.datastore.keys.SettingsKeys +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import javax.inject.Inject + +class NameFlow @Inject constructor( + private val dataStore: IvyDataStore, + private val settingsKeys: SettingsKeys +) : com.ivy.core.domain.action.FlowAction() { + override fun createFlow(input: Unit): Flow = + dataStore.get(settingsKeys.displayName) + .filterNotNull() +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/settings/name/WriteNameAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/settings/name/WriteNameAct.kt new file mode 100644 index 0000000..4b82f09 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/settings/name/WriteNameAct.kt @@ -0,0 +1,17 @@ +package com.ivy.core.domain.action.settings.name + +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.datastore.IvyDataStore +import com.ivy.core.persistence.datastore.keys.SettingsKeys +import javax.inject.Inject + +class WriteNameAct @Inject constructor( + private val dataStore: IvyDataStore, + private val settingsKeys: SettingsKeys +) : Action() { + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(name: String) { + dataStore.put(settingsKeys.displayName, name) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/settings/startdayofmonth/StartDayOfMonthFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/action/settings/startdayofmonth/StartDayOfMonthFlow.kt new file mode 100644 index 0000000..8d95f30 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/settings/startdayofmonth/StartDayOfMonthFlow.kt @@ -0,0 +1,21 @@ +package com.ivy.core.domain.action.settings.startdayofmonth + +import com.ivy.core.domain.action.SharedFlowAction +import com.ivy.core.persistence.datastore.IvyDataStore +import com.ivy.core.persistence.datastore.keys.SettingsKeys +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StartDayOfMonthFlow @Inject constructor( + private val dataStore: IvyDataStore, + private val settingsKeys: SettingsKeys, +) : SharedFlowAction() { + override fun initialValue(): Int = 1 + + override fun createFlow(): Flow = + dataStore.get(key = settingsKeys.startDayOfMonth) + .map { it ?: initialValue() } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/settings/startdayofmonth/WriteStartDayOfMonthAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/settings/startdayofmonth/WriteStartDayOfMonthAct.kt new file mode 100644 index 0000000..ee595ee --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/settings/startdayofmonth/WriteStartDayOfMonthAct.kt @@ -0,0 +1,17 @@ +package com.ivy.core.domain.action.settings.startdayofmonth + +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.datastore.IvyDataStore +import com.ivy.core.persistence.datastore.keys.SettingsKeys +import javax.inject.Inject + +class WriteStartDayOfMonthAct @Inject constructor( + private val dataStore: IvyDataStore, + private val settingsKeys: SettingsKeys, +) : Action() { + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(startDayOfMonth: Int) { + dataStore.put(key = settingsKeys.startDayOfMonth, value = startDayOfMonth) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/settings/theme/ThemeFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/action/settings/theme/ThemeFlow.kt new file mode 100644 index 0000000..9bc30e6 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/settings/theme/ThemeFlow.kt @@ -0,0 +1,18 @@ +package com.ivy.core.domain.action.settings.theme + +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.persistence.datastore.IvyDataStore +import com.ivy.core.persistence.datastore.keys.SettingsKeys +import com.ivy.data.Theme +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class ThemeFlow @Inject constructor( + private val dataStore: IvyDataStore, + private val settingsKeys: SettingsKeys +) : FlowAction() { + override fun createFlow(input: Unit): Flow = + dataStore.get(settingsKeys.theme) + .map { it?.let(Theme::fromCode) ?: Theme.Auto } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/settings/theme/WriteThemeAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/settings/theme/WriteThemeAct.kt new file mode 100644 index 0000000..36cd54c --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/settings/theme/WriteThemeAct.kt @@ -0,0 +1,18 @@ +package com.ivy.core.domain.action.settings.theme + +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.datastore.IvyDataStore +import com.ivy.core.persistence.datastore.keys.SettingsKeys +import com.ivy.data.Theme +import javax.inject.Inject + +class WriteThemeAct @Inject constructor( + private val dataStore: IvyDataStore, + private val settingsKeys: SettingsKeys +) : Action() { + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(them: Theme) { + dataStore.put(key = settingsKeys.theme, them.code) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/transaction/TrnByIdAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/TrnByIdAct.kt new file mode 100644 index 0000000..fe5da09 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/TrnByIdAct.kt @@ -0,0 +1,15 @@ +package com.ivy.core.domain.action.transaction + +import com.ivy.core.domain.action.Action +import com.ivy.data.transaction.Transaction +import java.util.* +import javax.inject.Inject + +class TrnByIdAct @Inject constructor( + private val trnsByQueryAct: TrnsByQueryAct, +) : Action() { + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(trnId: UUID): Transaction? = + trnsByQueryAct(TrnQuery.ById(trnId)).firstOrNull() +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/transaction/TrnQuery.kt b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/TrnQuery.kt new file mode 100644 index 0000000..51bc503 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/TrnQuery.kt @@ -0,0 +1,64 @@ +package com.ivy.core.domain.action.transaction + +import arrow.core.NonEmptyList +import com.ivy.core.persistence.query.TrnWhere +import com.ivy.data.time.TimeRange +import com.ivy.data.transaction.TransactionType +import com.ivy.data.transaction.TrnPurpose +import java.util.* + +sealed interface TrnQuery { + data class ById(val id: UUID) : TrnQuery + data class ByIdIn(val ids: NonEmptyList) : TrnQuery + + data class ByCategoryId(val categoryId: UUID?) : TrnQuery + data class ByCategoryIdIn(val categoryIds: NonEmptyList) : TrnQuery + + data class ByAccountId(val accountId: UUID) : TrnQuery + data class ByAccountIdIn(val accountIds: NonEmptyList) : TrnQuery + + data class ByType(val trnType: TransactionType) : TrnQuery + data class ByTypeIn(val types: NonEmptyList) : TrnQuery + + data class ByPurpose(val purpose: TrnPurpose?) : TrnQuery + data class ByPurposeIn(val purposes: NonEmptyList) : TrnQuery + + /** + * Inclusive period [from, to] + */ + data class DueBetween(val range: TimeRange) : TrnQuery + + /** + * Inclusive period [from, to] + */ + data class ActualBetween(val range: TimeRange) : TrnQuery + + data class Brackets(val cond: TrnQuery) : TrnQuery + data class And(val cond1: TrnQuery, val cond2: TrnQuery) : TrnQuery + data class Or(val cond1: TrnQuery, val cond2: TrnQuery) : TrnQuery + data class Not(val cond: TrnQuery) : TrnQuery +} + +fun brackets(cond: TrnQuery): TrnQuery.Brackets = TrnQuery.Brackets(cond) +infix fun TrnQuery.and(cond2: TrnQuery): TrnQuery.And = TrnQuery.And(this, cond2) +infix fun TrnQuery.or(cond2: TrnQuery): TrnQuery.Or = TrnQuery.Or(this, cond2) +fun not(cond: TrnQuery): TrnQuery.Not = TrnQuery.Not(cond) + +fun TrnQuery.toTrnWhere(): TrnWhere = when (this) { + is TrnQuery.ActualBetween -> TrnWhere.ActualBetween(range) + is TrnQuery.And -> TrnWhere.And(cond1.toTrnWhere(), cond2.toTrnWhere()) + is TrnQuery.Brackets -> TrnWhere.Brackets(cond.toTrnWhere()) + is TrnQuery.ByAccountId -> TrnWhere.ByAccountId(accountId.toString()) + is TrnQuery.ByAccountIdIn -> TrnWhere.ByAccountIdIn(accountIds.map { it.toString() }) + is TrnQuery.ByCategoryId -> TrnWhere.ByCategoryId(categoryId?.toString()) + is TrnQuery.ByCategoryIdIn -> TrnWhere.ByCategoryIdIn(categoryIds.map { it?.toString() }) + is TrnQuery.ById -> TrnWhere.ById(id.toString()) + is TrnQuery.ByIdIn -> TrnWhere.ByIdIn(ids.map { it.toString() }) + is TrnQuery.ByPurpose -> TrnWhere.ByPurpose(purpose) + is TrnQuery.ByPurposeIn -> TrnWhere.ByPurposeIn(purposes) + is TrnQuery.ByType -> TrnWhere.ByType(trnType) + is TrnQuery.ByTypeIn -> TrnWhere.ByTypeIn(types) + is TrnQuery.DueBetween -> TrnWhere.DueBetween(range) + is TrnQuery.Not -> TrnWhere.Not(cond.toTrnWhere()) + is TrnQuery.Or -> TrnWhere.Or(cond1.toTrnWhere(), cond2.toTrnWhere()) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/transaction/TrnsByQueryAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/TrnsByQueryAct.kt new file mode 100644 index 0000000..55143c3 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/TrnsByQueryAct.kt @@ -0,0 +1,16 @@ +package com.ivy.core.domain.action.transaction + +import com.ivy.core.domain.action.Action +import com.ivy.data.transaction.Transaction +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +class TrnsByQueryAct @Inject constructor( + private val trnsFlow: TrnsFlow, +) : Action>() { + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(query: TrnQuery): List = + trnsFlow(query).first() + +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/transaction/TrnsFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/TrnsFlow.kt new file mode 100644 index 0000000..0ef3ea3 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/TrnsFlow.kt @@ -0,0 +1,186 @@ +package com.ivy.core.domain.action.transaction + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.toLocal +import com.ivy.common.toUUID +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.domain.action.account.AccountsFlow +import com.ivy.core.domain.action.category.CategoriesFlow +import com.ivy.core.domain.pure.util.combineList +import com.ivy.core.domain.pure.util.flattenLatest +import com.ivy.core.persistence.dao.AttachmentDao +import com.ivy.core.persistence.dao.tag.TagDao +import com.ivy.core.persistence.dao.trn.TrnMetadataDao +import com.ivy.core.persistence.dao.trn.TrnTagDao +import com.ivy.core.persistence.entity.attachment.AttachmentEntity +import com.ivy.core.persistence.entity.tag.TagEntity +import com.ivy.core.persistence.entity.trn.TransactionEntity +import com.ivy.core.persistence.entity.trn.TrnMetadataEntity +import com.ivy.core.persistence.entity.trn.data.TrnTimeType +import com.ivy.core.persistence.query.* +import com.ivy.data.Sync +import com.ivy.data.SyncState +import com.ivy.data.Value +import com.ivy.data.account.Account +import com.ivy.data.attachment.Attachment +import com.ivy.data.category.Category +import com.ivy.data.tag.Tag +import com.ivy.data.transaction.Transaction +import com.ivy.data.transaction.TrnMetadata +import com.ivy.data.transaction.TrnTime +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.* +import java.util.* +import javax.inject.Inject + +/** + * Note: Deleted but not synced transactions aren't returned. + * @return a flow of domain **[[Transaction]]** by a given query. + * ## Query + * + * ### Filters + * - [TrnQuery.ByAccountId] + * - [TrnQuery.ByCategoryId] + * - [TrnQuery.ByType] + * - [TrnQuery.ActualBetween] + * - [TrnQuery.DueBetween] + * - [TrnQuery.ById] + * - see [TrnQuery] + * + * ### Building more complex query: + * - and() + * - or() + * - not() + * - brackets() + */ +@Deprecated("don't use it! It's inefficient.") +@OptIn(FlowPreview::class) +class TrnsFlow @Inject constructor( + private val accountsFlow: AccountsFlow, + private val categoriesFlow: CategoriesFlow, + private val queryExecutor: TrnQueryExecutor, + private val trnMetadataDao: TrnMetadataDao, + private val attachmentDao: AttachmentDao, + private val trnTagDao: TrnTagDao, + private val tagDao: TagDao, + private val trnsSignal: TrnsSignal, + private val timeProvider: TimeProvider, +) : FlowAction>() { + + override fun createFlow(input: TrnQuery): Flow> = combine( + accountsFlow(), categoriesFlow(), trnsSignal.receive() + ) { accs, cats, _ -> + val dbQuery = brackets(input.toTrnWhere()) and not(TrnWhere.BySync(SyncState.Deleting)) + val entities = queryExecutor.query(dbQuery) + if (entities.isEmpty()) { + return@combine flowOf(emptyList()) + } + + val accsMap = accs.associateBy { it.id } + val catsMap = cats.associateBy { it.id } + + combineList( + entities.mapNotNull { + mapTransactionEntityFlow( + accounts = accsMap, + categories = catsMap, + trn = it + ) + } + ) + }.flattenLatest() + .flowOn(Dispatchers.Default) + + @OptIn(ExperimentalCoroutinesApi::class) + private fun mapTransactionEntityFlow( + accounts: Map, + categories: Map, + trn: TransactionEntity, + ): Flow? { + val account = accounts[trn.accountId.toUUID()] + ?: return null + + val trnId = trn.id + val tagsFlow = trnTagDao.findByTrnId(trnId = trnId) + .flatMapLatest { trnTags -> + tagDao.findByTagIds(tagIds = trnTags.map { it.tagId }) + } + + return combine( + trnMetadataDao.findByTrnId(trnId = trnId), + tagsFlow, + attachmentDao.findByAssociatedId(associatedId = trnId) + ) { metadataEntities, tagEntities, attachmentEntities -> + Transaction( + id = trnId.toUUID(), + account = account, + type = trn.type, + value = Value( + amount = trn.amount, + currency = trn.currency + ), + category = categories[trn.categoryId?.toUUID()], + time = trnTime(trn), + title = trn.title.takeIf { !it.isNullOrBlank() }, + description = trn.description.takeIf { !it.isNullOrBlank() }, + state = trn.state, + purpose = trn.purpose, + sync = Sync( + state = trn.sync, + lastUpdated = trn.lastUpdated.toLocal(timeProvider) + ), + metadata = mapMetadata(metadataEntities), + attachments = mapAttachments(attachmentEntities), + tags = mapTags(tagEntities), + ) + } + } + + private fun trnTime(entity: TransactionEntity): TrnTime { + val localeTime = entity.time.toLocal(timeProvider) + return when (entity.timeType) { + TrnTimeType.Actual -> TrnTime.Actual(localeTime) + TrnTimeType.Due -> TrnTime.Due(localeTime) + } + } + + private fun mapMetadata(metadataEntities: List): TrnMetadata { + val metadata = metadataEntities.associate { it.key to it.value.toUUID() } + return TrnMetadata( + recurringRuleId = metadata[TrnMetadata.RECURRING_RULE_ID], + loanRecordId = metadata[TrnMetadata.LOAN_RECORD_ID], + loanId = metadata[TrnMetadata.LOAN_ID] + ) + } + + private fun mapAttachments(entities: List): List = entities.map { + Attachment( + id = it.id, + associatedId = it.id, + uri = it.id, + source = it.source, + filename = it.filename, + type = it.type, + sync = Sync( + state = it.sync, + lastUpdated = it.lastUpdated.toLocal(timeProvider) + ) + ) + } + + private fun mapTags(entities: List): List = entities.map { + Tag( + id = it.id, + color = it.color, + name = it.name, + orderNum = it.orderNum, + state = it.state, + sync = Sync( + state = it.sync, + lastUpdated = it.lastUpdated.toLocal(timeProvider) + ) + ) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/transaction/TrnsSignal.kt b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/TrnsSignal.kt new file mode 100644 index 0000000..0770c8a --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/TrnsSignal.kt @@ -0,0 +1,14 @@ +package com.ivy.core.domain.action.transaction + +import com.ivy.core.domain.action.SignalFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Notifies of new or modified transactions. + * Called whenever a transaction is written via [WriteTrnsAct] + */ +@Singleton +class TrnsSignal @Inject constructor() : SignalFlow() { + override fun initialSignal() = Unit +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/transaction/WriteTrnsAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/WriteTrnsAct.kt new file mode 100644 index 0000000..4d2d6ef --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/WriteTrnsAct.kt @@ -0,0 +1,286 @@ +package com.ivy.core.domain.action.transaction + +import arrow.core.* +import arrow.core.computations.option +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.toLocal +import com.ivy.common.time.toUtc +import com.ivy.common.toNonEmptyList +import com.ivy.core.domain.action.Action +import com.ivy.core.domain.action.data.Modify +import com.ivy.core.domain.algorithm.accountcache.InvalidateAccCacheAct +import com.ivy.core.domain.pure.mapping.entity.mapToEntity +import com.ivy.core.domain.pure.mapping.entity.mapToTrnTagEntity +import com.ivy.core.domain.pure.transaction.validateTransaction +import com.ivy.core.domain.pure.util.beautify +import com.ivy.core.persistence.IvyWalletCoreDb +import com.ivy.core.persistence.dao.trn.SaveTrnData +import com.ivy.core.persistence.dao.trn.TransactionDao +import com.ivy.core.persistence.entity.trn.TrnMetadataEntity +import com.ivy.core.persistence.entity.trn.data.TrnTimeType +import com.ivy.data.SyncState.Syncing +import com.ivy.data.transaction.Transaction +import com.ivy.data.transaction.TrnMetadata +import com.ivy.data.transaction.TrnTime +import java.util.* +import javax.inject.Inject + +/** + * Persists transactions locally. Supports sync out-of-the-box. + * See [Modify]. + * + * ## Save transactions + * ``` + * val writeTrnsAct: WriteTrnsAct // init via DI + * + * writeTrnsAct(Modify.save(trn)) // save a single transaction + * writeTrnsAct(Modify.saveMany(trns)) // saves multiple transactions + * ``` + * ## Delete transactions + * ``` + * val writeTrnsAct: WriteTrnsAct // init via DI + * + * writeTrnsAct(Modify.delete(trn.id.toString())) // deletes a transaction + * writeTrnsAct(Modify.deleteMany(trnIds.map { it.id.toString() })) // deletes multiple transactions + * ``` + */ +class WriteTrnsAct @Inject constructor( + private val transactionDao: TransactionDao, + private val trnsSignal: TrnsSignal, + private val timeProvider: TimeProvider, + private val invalidateAccCacheAct: InvalidateAccCacheAct, + private val db: IvyWalletCoreDb, +) : Action() { + + sealed interface Input { + sealed interface Operation + + data class Delete( + val trnId: String, + val affectedAccountIds: Set, + val originalTime: TrnTime, + ) : Input, Operation + + data class DeleteInefficient( + val trnId: String + ) : Input, Operation + + data class Update( + val old: Transaction, + val new: Transaction, + ) : Input, Operation + + data class CreateNew( + val trn: Transaction + ) : Input, Operation + + data class SaveInefficient( + val trn: Transaction + ) : Input, Operation + + // TODO: Re-work this Many to be efficient + data class ManyInefficient( + val operations: List + ) : Input + } + + override suspend fun action(input: Input) { + when (input) { + is Input.CreateNew -> createNew(input) + is Input.Update -> update(input) + is Input.Delete -> delete(input) + is Input.DeleteInefficient -> deleteInefficient(input) + is Input.SaveInefficient -> saveInefficient(input) + is Input.ManyInefficient -> many(input) + } + + trnsSignal.send(Unit) // notify for changed transactions + } + + // region Operations + private suspend fun createNew(input: Input.CreateNew) = option { + val saveData = saveData(input.trn).bind() + transactionDao.save(saveData) + invalidateAccCacheAct( + InvalidateAccCacheAct.Input.OnCreateTrn( + time = input.trn.time, + accountIds = input.trn.account.id.toString().nel() + ) + ) + } + + private suspend fun update(input: Input.Update) = option { + val saveData = saveData(input.new).bind() + transactionDao.save(saveData) + invalidateAccCacheAct( + InvalidateAccCacheAct.Input.OnUpdateTrn( + oldTime = input.old.time, + time = input.new.time, + accountIds = nonEmptyListOf( + input.old.account.id.toString(), + input.new.account.id.toString(), + ) + ) + ) + } + + private suspend fun delete(input: Input.Delete) = option { + transactionDao.markDeleted(input.trnId) + invalidateAccCacheAct( + InvalidateAccCacheAct.Input.OnDeleteTrn( + time = input.originalTime, + accountIds = input.affectedAccountIds.toNonEmptyList() + ) + ) + } + + private suspend fun deleteInefficient(input: Input.DeleteInefficient) = option { + val invalidateData = findInvalidateCacheData(input.trnId) + transactionDao.markDeleted(input.trnId) + invalidateData?.let { + invalidateAccCacheAct( + InvalidateAccCacheAct.Input.OnDeleteTrn( + time = it.time, + accountIds = it.accountId.nel() + ) + ) + } + } + + private suspend fun saveInefficient(input: Input.SaveInefficient) = option { + val saveData = saveData(input.trn).bind() + val trnExists = findInvalidateCacheData(input.trn.id.toString()) + transactionDao.save(saveData) + invalidateAccCacheAct( + if (trnExists != null) { + InvalidateAccCacheAct.Input.OnUpdateTrn( + oldTime = trnExists.time, + time = input.trn.time, + accountIds = nonEmptyListOf( + trnExists.accountId, + input.trn.account.id.toString() + ) + ) + } else { + InvalidateAccCacheAct.Input.OnCreateTrn( + time = input.trn.time, + accountIds = input.trn.account.id.toString().nel() + ) + } + ) + } + // endregion + + // region Many + private suspend fun many(input: Input.ManyInefficient) { + val pairs = input.operations.map { + when (it) { + is Input.CreateNew -> { + saveData(it.trn) to null + } + is Input.SaveInefficient -> { + saveData(it.trn) to null + } + is Input.Update -> { + saveData(it.new) to null + } + is Input.Delete -> { + null to it.trnId + } + is Input.DeleteInefficient -> { + null to it.trnId + } + } + } + + transactionDao.many( + toSave = pairs.mapNotNull { it.first?.orNull() }, + toDeleteTrnIds = pairs.mapNotNull { it.second } + ) + + // Invalidate all account's cache + db.accountCacheDao().deleteAll() + } + // endregion + + // region TrnSaveData + private fun saveData(trn: Transaction): Option { + if (!validateTransaction(trn)) return None + + val trnEntity = mapToEntity( + trn = trn.copy( + title = beautify(trn.title), + description = beautify(trn.description) + ), + timeProvider = timeProvider, + ).copy(sync = Syncing) + val trnId = trn.id.toString() + val tags = trn.tags.map { + mapToTrnTagEntity( + trnId = trnId, + tagId = it.id, + sync = it.sync.copy(state = Syncing), + timeProvider = timeProvider, + ) + } + val attachments = trn.attachments.map { + mapToEntity( + it, + timeProvider = timeProvider + ).copy(sync = Syncing) + } + val metadata = metadataEntities(trnId = trnId, metadata = trn.metadata) + + return SaveTrnData( + entity = trnEntity, + tags = tags, + attachments = attachments, + metadata = metadata + ).some() + } + + private fun metadataEntities( + trnId: String, metadata: TrnMetadata + ): List { + fun newMetadata( + key: String, + value: String, + ) = TrnMetadataEntity( + id = UUID.randomUUID().toString(), + trnId = trnId, + key = key, + value = value, + sync = Syncing, + lastUpdated = timeProvider.timeNow().toUtc(timeProvider) + ) + + fun metadata(key: String, value: UUID?): TrnMetadataEntity? = + value?.toString()?.let { + newMetadata(key = key, value = it) + } + + return listOfNotNull( + metadata(key = TrnMetadata.RECURRING_RULE_ID, value = metadata.recurringRuleId), + metadata(key = TrnMetadata.LOAN_ID, value = metadata.loanId), + metadata(key = TrnMetadata.LOAN_RECORD_ID, value = metadata.loanRecordId) + ) + } + // endregion + + private suspend fun findInvalidateCacheData( + trnId: String + ): InvalidateCacheData? = transactionDao.findAccountIdAndTimeById(trnId = trnId)?.let { + InvalidateCacheData( + accountId = it.accountId, + time = when (it.timeType) { + TrnTimeType.Actual -> TrnTime.Actual(it.time.toLocal(timeProvider)) + TrnTimeType.Due -> TrnTime.Due(it.time.toLocal(timeProvider)) + } + ) + } + + data class InvalidateCacheData( + val accountId: String, + val time: TrnTime + ) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/transaction/WriteTrnsBatchAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/WriteTrnsBatchAct.kt new file mode 100644 index 0000000..d494b99 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/WriteTrnsBatchAct.kt @@ -0,0 +1,87 @@ +package com.ivy.core.domain.action.transaction + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.toUtc +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.dao.trn.TrnLinkRecordDao +import com.ivy.core.persistence.entity.trn.TrnLinkRecordEntity +import com.ivy.data.Sync +import com.ivy.data.SyncState +import com.ivy.data.transaction.TrnBatch +import java.util.* +import javax.inject.Inject + +/** + * Saves or deletes a batch of transactions. + * + * Use: + * - [WriteTrnsBatchAct.save]: to save a [TrnBatch] + * - [WriteTrnsBatchAct.delete]: to delete a [TrnBatch] + */ +class WriteTrnsBatchAct @Inject constructor( + private val writeTrnsAct: WriteTrnsAct, + private val trnLinkRecordDao: TrnLinkRecordDao, + private val timeProvider: TimeProvider, +) : Action() { + companion object { + fun save(batch: TrnBatch) = ModifyBatch.Save(batch) + fun delete(batch: TrnBatch) = ModifyBatch.Delete(batch) + } + + sealed interface ModifyBatch { + data class Save internal constructor(val batch: TrnBatch) : ModifyBatch + data class Delete internal constructor(val batch: TrnBatch) : ModifyBatch + } + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(modify: ModifyBatch) { + when (modify) { + is ModifyBatch.Delete -> delete(modify.batch) + is ModifyBatch.Save -> save(modify.batch) + } + + //Note: writeTrnsAct will notify of transactions update + } + + private suspend fun delete(batch: TrnBatch) { + val trnIds = batch.trns.map { it.id.toString() } + batch.trns.forEach { + // TODO: Might corrupt the cache + writeTrnsAct( + WriteTrnsAct.Input.Delete( + trnId = it.id.toString(), + affectedAccountIds = setOf(it.account.id.toString()), + originalTime = it.time, + ) + ) + } + trnLinkRecordDao.updateSyncByTrnIds(trnIds = trnIds, sync = SyncState.Deleting) + } + + private suspend fun save(batch: TrnBatch) { + batch.trns.map { + it.copy( + sync = Sync( + state = SyncState.Syncing, + lastUpdated = timeProvider.timeNow(), + ) + ) + }.forEach { + writeTrnsAct(WriteTrnsAct.Input.SaveInefficient(it)) + } + + + trnLinkRecordDao.save( + batch.trns.map { + TrnLinkRecordEntity( + id = UUID.randomUUID().toString(), + trnId = it.id.toString(), + batchId = batch.batchId, + sync = SyncState.Syncing, + lastUpdated = timeProvider.timeNow().toUtc(timeProvider) + ) + } + ) + } + +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/transaction/transfer/ModifyTransfer.kt b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/transfer/ModifyTransfer.kt new file mode 100644 index 0000000..5127051 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/transfer/ModifyTransfer.kt @@ -0,0 +1,34 @@ +package com.ivy.core.domain.action.transaction.transfer + +import com.ivy.data.transaction.Transfer +import com.ivy.data.transaction.TrnTime + +sealed interface ModifyTransfer { + companion object { + fun add(data: TransferData, batchId: String? = null) = Add(data = data, batchId = batchId) + + fun edit(batchId: String, data: TransferData) = Edit(batchId, data) + + fun updateTrnTime(batchId: String, newTrnTime: TrnTime) = + UpdateTrnTime(batchId, newTrnTime) + + fun delete(transfer: Transfer) = Delete(transfer) + } + + data class UpdateTrnTime internal constructor( + val batchId: String, + val newTrnTime: TrnTime, + ) : ModifyTransfer + + data class Add internal constructor( + val batchId: String?, + val data: TransferData + ) : ModifyTransfer + + data class Edit internal constructor( + val batchId: String, + val data: TransferData + ) : ModifyTransfer + + data class Delete internal constructor(val transfer: Transfer) : ModifyTransfer +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/transaction/transfer/TransferByBatchIdAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/transfer/TransferByBatchIdAct.kt new file mode 100644 index 0000000..57d09bb --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/transfer/TransferByBatchIdAct.kt @@ -0,0 +1,41 @@ +package com.ivy.core.domain.action.transaction.transfer + +import com.ivy.common.toNonEmptyList +import com.ivy.common.toUUID +import com.ivy.core.domain.action.Action +import com.ivy.core.domain.action.transaction.TrnQuery +import com.ivy.core.domain.action.transaction.TrnsByQueryAct +import com.ivy.core.persistence.dao.trn.TrnLinkRecordDao +import com.ivy.data.transaction.Transfer +import com.ivy.data.transaction.TrnPurpose +import javax.inject.Inject + +class TransferByBatchIdAct @Inject constructor( + private val trnsByQueryAct: TrnsByQueryAct, + private val trnLinkRecordDao: TrnLinkRecordDao, +) : Action() { + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(batchId: String): Transfer? { + val trnIds = trnLinkRecordDao.findByBatchId(batchId = batchId) + .map { it.trnId } + if (trnIds.isEmpty()) return null + val trns = trnsByQueryAct( + TrnQuery.ByIdIn( + trnIds.map { it.toUUID() }.toNonEmptyList() + ) + ) + + val from = trns.firstOrNull { it.purpose == TrnPurpose.TransferFrom } ?: return null + val to = trns.firstOrNull { it.purpose == TrnPurpose.TransferTo } ?: return null + val fee = trns.firstOrNull { it.purpose == TrnPurpose.Fee } + + return Transfer( + batchId = batchId, + time = from.time, + from = from, + to = to, + fee = fee, + ) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/transaction/transfer/TransferData.kt b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/transfer/TransferData.kt new file mode 100644 index 0000000..662f609 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/transfer/TransferData.kt @@ -0,0 +1,20 @@ +package com.ivy.core.domain.action.transaction.transfer + +import com.ivy.data.Sync +import com.ivy.data.Value +import com.ivy.data.account.Account +import com.ivy.data.category.Category +import com.ivy.data.transaction.TrnTime + +data class TransferData( + val amountFrom: Value, + val amountTo: Value, + val accountFrom: Account, + val accountTo: Account, + val category: Category?, + val time: TrnTime, + val title: String?, + val description: String?, + val fee: Value?, + val sync: Sync, +) \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/action/transaction/transfer/WriteTransferAct.kt b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/transfer/WriteTransferAct.kt new file mode 100644 index 0000000..43095f3 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/action/transaction/transfer/WriteTransferAct.kt @@ -0,0 +1,241 @@ +package com.ivy.core.domain.action.transaction.transfer + +import com.ivy.core.domain.action.Action +import com.ivy.core.domain.action.transaction.WriteTrnsAct +import com.ivy.core.domain.action.transaction.WriteTrnsBatchAct +import com.ivy.core.domain.pure.transaction.transfer.validateTransfer +import com.ivy.data.Sync +import com.ivy.data.Value +import com.ivy.data.transaction.* +import java.util.* +import javax.inject.Inject + +class WriteTransferAct @Inject constructor( + private val writeTrnsBatchAct: WriteTrnsBatchAct, + private val transferByBatchIdAct: TransferByBatchIdAct, + private val writeTrnsAct: WriteTrnsAct, +) : Action() { + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(modify: ModifyTransfer) { + when (modify) { + is ModifyTransfer.Add -> addTransfer(modify.batchId, modify.data) + is ModifyTransfer.Edit -> editTransfer(modify.batchId, modify.data) + is ModifyTransfer.Delete -> deleteTransfer(modify.transfer) + is ModifyTransfer.UpdateTrnTime -> updateTrnTime(modify.batchId, modify.newTrnTime) + } + } + + private suspend fun updateTrnTime(batchId: String, newTime: TrnTime) { + val transfer = transferByBatchIdAct(batchId) ?: return + listOfNotNull(transfer.from, transfer.to, transfer.fee).forEach { trn -> + val actualTrn = trn.copy(time = newTime) + writeTrnsAct( + WriteTrnsAct.Input.Update( + old = trn, + new = actualTrn + ) + ) + } + } + + private suspend fun addTransfer( + batchId: String?, + data: TransferData + ) { + if (!validateTransfer(data)) return + + val trns = mutableListOf() + val metadata = TrnMetadata( + recurringRuleId = null, + loanId = null, + loanRecordId = null, + ) + + // FROM + trns.add( + Transaction( + id = UUID.randomUUID(), + account = data.accountFrom, + value = data.amountFrom, + category = data.category, + title = data.title, + description = data.description, + time = data.time, + type = TransactionType.Expense, + purpose = TrnPurpose.TransferFrom, + metadata = metadata, + attachments = emptyList(), + tags = emptyList(), + state = TrnState.Default, + sync = data.sync, + ) + ) + + // TO + trns.add( + Transaction( + id = UUID.randomUUID(), + account = data.accountTo, + value = data.amountTo, + category = data.category, + title = data.title, + description = data.description, + time = data.time, + type = TransactionType.Income, + purpose = TrnPurpose.TransferTo, + metadata = metadata, + attachments = emptyList(), + tags = emptyList(), + state = TrnState.Default, + sync = data.sync, + ) + ) + + // FEE + if (data.fee != null) { + trns.add( + fee( + data = data, + fee = data.fee, + metadata = metadata, + sync = data.sync, + ) + ) + } + + writeTrnsBatchAct( + WriteTrnsBatchAct.save( + TrnBatch( + batchId = batchId ?: UUID.randomUUID().toString(), + trns = trns + ) + ) + ) + } + + private suspend fun editTransfer( + batchId: String, + data: TransferData + ) { + if (!validateTransfer(data)) return + val transfer = transferByBatchIdAct(batchId) ?: return + + val trns = mutableListOf() + + // FROM + trns.add( + transfer.from.copy( + account = data.accountFrom, + value = data.amountFrom, + category = data.category, + title = data.title, + description = data.description, + time = data.time, + sync = data.sync, + ) + ) + + // TO + trns.add( + transfer.to.copy( + account = data.accountTo, + value = data.amountTo, + category = data.category, + title = data.title, + description = data.description, + time = data.time, + sync = data.sync, + ) + ) + + // FEE + if (data.fee != null) { + // will have fee + val existingFee = transfer.fee + if (existingFee != null) { + // update existing fee + trns.add( + existingFee.copy( + account = data.accountFrom, + category = data.category, + title = data.title, + description = data.description, + time = data.time, + value = data.fee, + sync = data.sync, + ) + ) + } else { + // add new fee + trns.add( + fee( + data = data, + fee = data.fee, + sync = data.sync, + ) + ) + } + } else { + // will have NO fee + transfer.fee?.let { fee -> + // remove existing fee if any + writeTrnsAct( + WriteTrnsAct.Input.Delete( + trnId = fee.id.toString(), + affectedAccountIds = setOf(fee.account.id.toString()), + originalTime = fee.time + ) + ) + } + } + + writeTrnsBatchAct( + WriteTrnsBatchAct.save( + TrnBatch( + batchId = batchId, + trns = trns + ) + ) + ) + } + + private fun fee( + data: TransferData, + fee: Value, + sync: Sync, + metadata: TrnMetadata = TrnMetadata( + recurringRuleId = null, + loanId = null, + loanRecordId = null, + ) + ): Transaction = Transaction( + id = UUID.randomUUID(), + account = data.accountFrom, + value = fee, + category = data.category, + title = data.title, + description = data.description, + time = data.time, + type = TransactionType.Expense, + purpose = TrnPurpose.Fee, + metadata = metadata, + attachments = emptyList(), + tags = emptyList(), + state = TrnState.Default, + sync = sync, + ) + + private suspend fun deleteTransfer( + transfer: Transfer + ) { + writeTrnsBatchAct( + WriteTrnsBatchAct.delete( + TrnBatch( + batchId = transfer.batchId, + trns = listOfNotNull(transfer.from, transfer.to, transfer.fee) + ) + ) + ) + } +} diff --git a/core/domain/src/main/java/com/ivy/core/domain/algorithm/accountcache/AccountCacheMapper.kt b/core/domain/src/main/java/com/ivy/core/domain/algorithm/accountcache/AccountCacheMapper.kt new file mode 100644 index 0000000..ff20b83 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/algorithm/accountcache/AccountCacheMapper.kt @@ -0,0 +1,50 @@ +package com.ivy.core.domain.algorithm.accountcache + +import arrow.core.Option +import com.ivy.core.domain.algorithm.calc.data.RawStats +import com.ivy.core.persistence.algorithm.accountcache.AccountCacheEntity +import com.ivy.data.CurrencyCode +import org.json.JSONObject + +fun rawStatsToAccountCache( + accountId: String, + rawStats: RawStats, +): AccountCacheEntity { + fun mapToJson(map: Map): String { + val json = JSONObject() + map.forEach { (currency, amount) -> + json.put(currency, amount) + } + return json.toString() + } + + return AccountCacheEntity( + accountId = accountId, + incomesJson = mapToJson(rawStats.incomes), + expensesJson = mapToJson(rawStats.expenses), + incomesCount = rawStats.incomesCount, + expensesCount = rawStats.expensesCount, + timestamp = rawStats.newestTrnTime, + ) +} + +fun accountCacheToRawStats(cache: AccountCacheEntity): Option { + fun jsonToMap(jsonString: String): Map { + val json = JSONObject(jsonString) + val map = mutableMapOf() + json.keys().forEach { key -> + map[key] = json.getDouble(key) + } + return map + } + + return Option.catch { + RawStats( + incomes = jsonToMap(cache.incomesJson), + expenses = jsonToMap(cache.expensesJson), + incomesCount = cache.incomesCount, + expensesCount = cache.expensesCount, + newestTrnTime = cache.timestamp + ) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/algorithm/accountcache/InvalidateAccCacheAct.kt b/core/domain/src/main/java/com/ivy/core/domain/algorithm/accountcache/InvalidateAccCacheAct.kt new file mode 100644 index 0000000..b3f581f --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/algorithm/accountcache/InvalidateAccCacheAct.kt @@ -0,0 +1,105 @@ +package com.ivy.core.domain.algorithm.accountcache + +import arrow.core.NonEmptyList +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.toUtc +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.IvyWalletCoreDb +import com.ivy.data.transaction.TrnTime +import java.time.Instant +import javax.inject.Inject + +/** + * Account-Cache algo: + * https://github.com/Ivy-Apps/ivy-wallet/blob/develop/docs/algorithms/Account-Cache%20Algo.md + */ +class InvalidateAccCacheAct @Inject constructor( + private val db: IvyWalletCoreDb, + private val timeProvider: TimeProvider, +) : Action() { + sealed interface Input { + /** + * Account ids of the caches that might be affected + */ + val accountIds: NonEmptyList + + data class OnDeleteAcc(override val accountIds: NonEmptyList) : Input + data class OnDeleteTrn( + val time: TrnTime, + override val accountIds: NonEmptyList + ) : Input + + data class OnCreateTrn( + val time: TrnTime, + override val accountIds: NonEmptyList + ) : Input + + data class OnUpdateTrn( + val oldTime: TrnTime, + val time: TrnTime, + override val accountIds: NonEmptyList + ) : Input + + data class Invalidate(override val accountIds: NonEmptyList) : Input + } + + override suspend fun action(input: Input) { + when (input) { + is Input.OnDeleteAcc -> { + invalidateCache(input.accountIds.head) + } + is Input.Invalidate -> { + input.accountIds.forEach { invalidateCache(it) } + } + else -> input.accountIds.forEach { + ensureCacheConsistency(it, input) + } + } + } + + private suspend fun ensureCacheConsistency(accountId: String, input: Input) { + val cacheTime = db.accountCacheDao().findTimestampById(accountId) + if (cacheTime != null) { + val (oldTime, time) = when (input) { + is Input.OnCreateTrn -> null to input.time + is Input.OnDeleteTrn -> null to input.time + is Input.OnUpdateTrn -> input.oldTime to input.time + else -> error("Impossible!") + } + + if (!cacheValid(cacheTime, oldTime, time)) { + invalidateCache(accountId) + } + } + } + + private fun cacheValid( + cacheTime: Instant, + oldTime: TrnTime?, + time: TrnTime + ): Boolean { + fun trnIsAfterCache(): Boolean { + val actual = time.actualTime() + return actual != null && actual > cacheTime + } + + /** + * Handles the case of changing trns times from the past (before cache) to the future + */ + fun wasNotBeforeCache(): Boolean { + val oldActual = oldTime?.actualTime() + return oldActual != null && oldActual > cacheTime + } + + return trnIsAfterCache() && wasNotBeforeCache() + } + + private fun TrnTime.actualTime(): Instant? = when (this) { + is TrnTime.Actual -> actual.toUtc(timeProvider) + is TrnTime.Due -> null // due transactions doesn't affect raw stats + } + + private suspend fun invalidateCache(accountId: String) { + db.accountCacheDao().delete(accountId) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/algorithm/accountcache/NukeAccountCacheAct.kt b/core/domain/src/main/java/com/ivy/core/domain/algorithm/accountcache/NukeAccountCacheAct.kt new file mode 100644 index 0000000..4a6c8f6 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/algorithm/accountcache/NukeAccountCacheAct.kt @@ -0,0 +1,13 @@ +package com.ivy.core.domain.algorithm.accountcache + +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.IvyWalletCoreDb +import javax.inject.Inject + +class NukeAccountCacheAct @Inject constructor( + private val db: IvyWalletCoreDb +) : Action() { + override suspend fun action(input: Unit) { + db.accountCacheDao().deleteAll() + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/algorithm/accountcache/RawAccStatsFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/algorithm/accountcache/RawAccStatsFlow.kt new file mode 100644 index 0000000..bb1df8e --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/algorithm/accountcache/RawAccStatsFlow.kt @@ -0,0 +1,76 @@ +package com.ivy.core.domain.algorithm.accountcache + +import arrow.core.None +import arrow.core.Some +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.domain.algorithm.calc.data.RawStats +import com.ivy.core.domain.algorithm.calc.plus +import com.ivy.core.domain.algorithm.calc.rawStats +import com.ivy.core.persistence.IvyWalletCoreDb +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +/** + * Account-Cache algo: + * https://github.com/Ivy-Apps/ivy-wallet/blob/develop/docs/algorithms/Account-Cache%20Algo.md + */ +class RawAccStatsFlow @Inject constructor( + private val db: IvyWalletCoreDb +) : FlowAction() { + + @OptIn(ExperimentalCoroutinesApi::class) + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override fun createFlow(accountId: String): Flow { + val accountCacheFlow = db.accountCacheDao().findAccountCache(accountId) + + return accountCacheFlow.flatMapLatest { cache -> + when (val cachedStats = cache?.let(::accountCacheToRawStats)) { + is Some -> withCache( + accountId = accountId, + cachedStats = cachedStats.value, + ) + null, None -> fromScratch(accountId) + } + } + } + + private fun withCache( + accountId: String, + cachedStats: RawStats, + ): Flow = db.calcTrnDao().findActualByAccountAfter( + accountId = accountId, + timestamp = cachedStats.newestTrnTime + ).map { newerTrns -> + if (newerTrns.isEmpty()) { + // No new transactions, the result will be the cache value + return@map cachedStats + } + + val newerStats = rawStats(newerTrns) + val result = cachedStats + newerStats + + updateCache(accountId, result) + + result + } + + private fun fromScratch( + accountId: String + ): Flow = db.calcTrnDao() + .findAllActualByAccount(accountId) + .map { trns -> + val result = rawStats(trns) + updateCache(accountId, result) + result + } + + private suspend fun updateCache(accountId: String, stats: RawStats) { + db.accountCacheDao().save( + rawStatsToAccountCache(accountId, stats) + ) + } +} + diff --git a/core/domain/src/main/java/com/ivy/core/domain/algorithm/balance/AccBalanceFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/algorithm/balance/AccBalanceFlow.kt new file mode 100644 index 0000000..b26c53a --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/algorithm/balance/AccBalanceFlow.kt @@ -0,0 +1,37 @@ +package com.ivy.core.domain.algorithm.balance + +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.domain.algorithm.accountcache.RawAccStatsFlow +import com.ivy.core.domain.algorithm.calc.RatesFlow +import com.ivy.core.domain.algorithm.calc.exchangeRawStats +import com.ivy.data.Value +import com.ivy.data.account.Account +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class AccBalanceFlow @Inject constructor( + private val rawAccStatsFlow: RawAccStatsFlow, + private val ratesFlow: RatesFlow, +) : FlowAction() { + + @OptIn(ExperimentalCoroutinesApi::class) + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override fun createFlow( + account: Account + ): Flow = rawAccStatsFlow(account.id.toString()).flatMapLatest { rawStats -> + ratesFlow().map { rates -> + val stats = exchangeRawStats( + rawStats = rawStats, + rates = rates, + outputCurrency = account.currency + ) + Value( + amount = stats.income.amount - stats.expense.amount, + currency = account.currency + ) + } + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/algorithm/balance/TotalBalanceFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/algorithm/balance/TotalBalanceFlow.kt new file mode 100644 index 0000000..7e303ee --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/algorithm/balance/TotalBalanceFlow.kt @@ -0,0 +1,48 @@ +package com.ivy.core.domain.algorithm.balance + +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.domain.action.account.AccountsFlow +import com.ivy.core.domain.algorithm.accountcache.RawAccStatsFlow +import com.ivy.core.domain.algorithm.calc.RatesFlow +import com.ivy.core.domain.algorithm.calc.exchangeRawStats +import com.ivy.data.Value +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +class TotalBalanceFlow @Inject constructor( + private val accountsFlow: AccountsFlow, + private val ratesFlow: RatesFlow, + private val rawAccStatsFlow: RawAccStatsFlow, +) : FlowAction() { + @JvmInline + value class Input( + val withExcluded: Boolean, + ) + + @OptIn(ExperimentalCoroutinesApi::class) + override fun createFlow(input: Input): Flow = accountsFlow().flatMapLatest { accounts -> + val included = if (input.withExcluded) accounts else accounts.filter { !it.excluded } + if (included.isEmpty()) { + flowOf(emptyList()) + } else { + combine( + included.map { rawAccStatsFlow(it.id.toString()) } + ) { includedRawStats -> + includedRawStats.toList() + } + } + }.flatMapLatest { includedRawStats -> + ratesFlow().map { rates -> + val balanceAmount = includedRawStats.sumOf { accRawStats -> + val stats = exchangeRawStats( + rawStats = accRawStats, + rates = rates, + outputCurrency = rates.baseCurrency, + ) + stats.income.amount - stats.expense.amount + } + Value(balanceAmount, rates.baseCurrency) + } + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/algorithm/calc/ExchangeRawStats.kt b/core/domain/src/main/java/com/ivy/core/domain/algorithm/calc/ExchangeRawStats.kt new file mode 100644 index 0000000..b0548af --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/algorithm/calc/ExchangeRawStats.kt @@ -0,0 +1,40 @@ +package com.ivy.core.domain.algorithm.calc + +import com.ivy.core.domain.algorithm.calc.data.RawStats +import com.ivy.core.domain.algorithm.calc.data.Stats +import com.ivy.core.domain.pure.exchange.exchange +import com.ivy.data.CurrencyCode +import com.ivy.data.Value +import com.ivy.data.exchange.ExchangeRates + +suspend fun exchangeRawStats( + rawStats: RawStats, + rates: ExchangeRates, + outputCurrency: CurrencyCode +): Stats { + var income = 0.0 + var expense = 0.0 + + rawStats.incomes.forEach { (currency, amount) -> + income += rates.exchange( + from = currency, + to = outputCurrency, + amount = amount + ) + } + + rawStats.expenses.forEach { (currency, amount) -> + expense += rates.exchange( + from = currency, + to = outputCurrency, + amount = amount + ) + } + + return Stats( + income = Value(income, outputCurrency), + expense = Value(expense, outputCurrency), + incomesCount = rawStats.incomesCount, + expensesCount = rawStats.expensesCount + ) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/algorithm/calc/RatesFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/algorithm/calc/RatesFlow.kt new file mode 100644 index 0000000..a3f8e8c --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/algorithm/calc/RatesFlow.kt @@ -0,0 +1,52 @@ +package com.ivy.core.domain.algorithm.calc + +import com.ivy.core.domain.action.SharedFlowAction +import com.ivy.core.domain.action.settings.basecurrency.BaseCurrencyFlow +import com.ivy.core.persistence.IvyWalletCoreDb +import com.ivy.data.CurrencyCode +import com.ivy.data.exchange.ExchangeRates +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RatesFlow @Inject constructor( + private val baseCurrencyFlow: BaseCurrencyFlow, + private val db: IvyWalletCoreDb, +) : SharedFlowAction() { + override fun initialValue() = ExchangeRates( + baseCurrency = "", + rates = emptyMap() + ) + + @OptIn(ExperimentalCoroutinesApi::class) + override fun createFlow(): Flow = baseCurrencyFlow() + .flatMapLatest { baseCurrency -> + if (baseCurrency.isBlank()) { + flowOf(initialValue()) + } else { + combine( + db.ratesDao().findAll(baseCurrency), + db.ratesDao().findAllOverrides(baseCurrency) + ) { rates, overrides -> + val finalRates = mutableMapOf() + // Automatic (remotely fetched) rates + rates.forEach { entry -> + finalRates[entry.currency] = entry.rate + } + // Manually overridden or custom added rates + overrides.forEach { entry -> + finalRates[entry.currency] = entry.rate + } + ExchangeRates( + baseCurrency = baseCurrency, + rates = finalRates, + ) + } + } + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/algorithm/calc/RawStats.kt b/core/domain/src/main/java/com/ivy/core/domain/algorithm/calc/RawStats.kt new file mode 100644 index 0000000..2ba4395 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/algorithm/calc/RawStats.kt @@ -0,0 +1,87 @@ +package com.ivy.core.domain.algorithm.calc + +import com.ivy.core.domain.algorithm.calc.data.RawStats +import com.ivy.core.persistence.algorithm.calc.CalcTrn +import com.ivy.data.CurrencyCode +import com.ivy.data.transaction.TransactionType +import java.time.Instant + +/** + * + */ +fun rawStats(trns: List): RawStats { + val incomes = mutableMapOf() + val expenses = mutableMapOf() + var incomesCount = 0 + var expensesCount = 0 + + var newestTrnTime = Instant.MIN + + trns.forEach { trn -> + when (trn.type) { + TransactionType.Income -> { + incomesCount++ + incomes.aggregate(trn) + } + TransactionType.Expense -> { + expensesCount++ + expenses.aggregate(trn) + } + } + if (trn.time > newestTrnTime) { + newestTrnTime = trn.time + } + } + + return RawStats( + incomes = incomes, + expenses = expenses, + incomesCount = incomesCount, + expensesCount = expensesCount, + newestTrnTime = newestTrnTime, + ) +} + +/** + * Sums all values in two [RawStats] instances. + * + * @return RawStats picking the largest newestTrnTimeo + * + * Complexity: + * **O(m+n) space-time** + * where: + * - m = Left's RawStats incomes & expenses maps size + * - n = Right's RawStats incomes & expenses maps size + */ +infix operator fun RawStats.plus(other: RawStats): RawStats { + fun sumMaps( + map1: Map, + map2: Map + ): Map { + val sum = mutableMapOf() + map1.forEach(sum::aggregate) + map2.forEach(sum::aggregate) + return sum + } + + return RawStats( + incomes = sumMaps(incomes, other.incomes), + expenses = sumMaps(expenses, other.expenses), + incomesCount = incomesCount + other.incomesCount, + expensesCount = expensesCount + other.expensesCount, + newestTrnTime = maxOf(newestTrnTime, other.newestTrnTime) + ) +} + +private fun MutableMap.aggregate( + trn: CalcTrn +) = aggregate(currency = trn.currency, amount = trn.amount) + +private fun MutableMap.aggregate( + currency: CurrencyCode, + amount: Double, +) { + compute(currency) { _, oldValue -> + (oldValue ?: 0.0) + amount + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/algorithm/calc/data/RawStats.kt b/core/domain/src/main/java/com/ivy/core/domain/algorithm/calc/data/RawStats.kt new file mode 100644 index 0000000..2a35504 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/algorithm/calc/data/RawStats.kt @@ -0,0 +1,12 @@ +package com.ivy.core.domain.algorithm.calc.data + +import com.ivy.data.CurrencyCode +import java.time.Instant + +data class RawStats( + val incomes: Map, + val expenses: Map, + val incomesCount: Int, + val expensesCount: Int, + val newestTrnTime: Instant, +) \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/algorithm/calc/data/Stats.kt b/core/domain/src/main/java/com/ivy/core/domain/algorithm/calc/data/Stats.kt new file mode 100644 index 0000000..3e7a910 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/algorithm/calc/data/Stats.kt @@ -0,0 +1,16 @@ +package com.ivy.core.domain.algorithm.calc.data + +import com.ivy.data.Value + +/** + * [RawStats] exchanged in an output currency. + * + * @param incomesCount the count of the income transactions + * @param expense the count of the expense transactions + */ +data class Stats( + val income: Value, + val expense: Value, + val incomesCount: Int, + val expensesCount: Int, +) \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/algorithm/trnhistory/CollapsedTrnListKeysFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/algorithm/trnhistory/CollapsedTrnListKeysFlow.kt new file mode 100644 index 0000000..cd3f9ce --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/algorithm/trnhistory/CollapsedTrnListKeysFlow.kt @@ -0,0 +1,40 @@ +package com.ivy.core.domain.algorithm.trnhistory + +import com.ivy.core.domain.action.SharedFlowAction +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +const val UpcomingSectionKey = "sec_upcoming" +const val OverdueSectionKey = "sec_overdue" + +private val collapsedTrnListKeys = MutableStateFlow( + // Upcoming & Overdue must be collapsed by default + setOf( + UpcomingSectionKey, + OverdueSectionKey + ) +) + +fun toggleCollapseExpandTrnListKey(keyId: String) { + collapsedTrnListKeys.value = collapsedTrnListKeys.value + .toMutableSet() + .apply { + if (keyId in this) { + remove(keyId) + } else { + add(keyId) + } + } +} + +@Singleton +class CollapsedTrnListKeysFlow @Inject constructor() : SharedFlowAction>() { + override fun initialValue(): Set = setOf( + // Upcoming & Overdue must be collapsed by default + UpcomingSectionKey, OverdueSectionKey + ) + + override fun createFlow(): Flow> = collapsedTrnListKeys +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/api/action/read/AccountRawStatsFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/api/action/read/AccountRawStatsFlow.kt new file mode 100644 index 0000000..38896b9 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/api/action/read/AccountRawStatsFlow.kt @@ -0,0 +1,53 @@ +package com.ivy.core.domain.api.action.read + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.data.AccountId +import com.ivy.core.data.calculation.AccountCache +import com.ivy.core.data.calculation.RawStats +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.domain.calculation.account.accountRawStats +import com.ivy.core.domain.calculation.account.cache.accountRawStatsWithCache +import com.ivy.core.persistence.api.account.AccountCacheRead +import com.ivy.core.persistence.api.account.AccountCacheWrite +import com.ivy.core.persistence.api.transaction.LedgerQuery +import com.ivy.core.persistence.api.transaction.LedgerRead +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +class AccountRawStatsFlow @Inject constructor( + private val ledgerRead: LedgerRead, + private val accountCacheRead: AccountCacheRead, + private val accountCacheWrite: AccountCacheWrite, + private val timeProvider: TimeProvider, +) : FlowAction() { + @OptIn(ExperimentalCoroutinesApi::class) + override fun createFlow(input: AccountId): Flow = + accountCacheRead.single(input).flatMapLatest { cache -> + if (cache != null) calculateWithCache(cache) else calculateWithoutCache(input) + } + + private fun calculateWithCache(cache: AccountCache): Flow = ledgerRead.many( + LedgerQuery.ForAccountAfter(cache.accountId, cache.rawStats.newestTransaction) + ).map { entriesAfterCache -> + accountRawStatsWithCache(cache, entriesAfterCache) + }.updateAccountCache(cache.accountId) + + private fun calculateWithoutCache(accountId: AccountId): Flow = ledgerRead.many( + LedgerQuery.ForAccount(accountId) + ).map { allEntries -> + accountRawStats(accountId, allEntries) + }.updateAccountCache(accountId) + + private fun Flow.updateAccountCache(accountId: AccountId) = + onEach { rawStats -> + val accountCache = AccountCache( + accountId = accountId, + rawStats = rawStats, + ) + accountCacheWrite.save(accountCache) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/api/action/read/IvyWalletDataFromPartialAct.kt b/core/domain/src/main/java/com/ivy/core/domain/api/action/read/IvyWalletDataFromPartialAct.kt new file mode 100644 index 0000000..1cada42 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/api/action/read/IvyWalletDataFromPartialAct.kt @@ -0,0 +1,59 @@ +package com.ivy.core.domain.api.action.read + +import arrow.core.Either +import com.ivy.core.data.* +import com.ivy.core.data.sync.* +import com.ivy.core.domain.action.Action +import com.ivy.core.domain.api.data.ActionError +import com.ivy.core.persistence.api.ReadSyncable +import com.ivy.core.persistence.api.account.AccountRead +import com.ivy.core.persistence.api.attachment.AttachmentRead +import com.ivy.core.persistence.api.budget.BudgetRead +import com.ivy.core.persistence.api.category.CategoryRead +import com.ivy.core.persistence.api.recurring.RecurringRuleRead +import com.ivy.core.persistence.api.saving.SavingGoalRead +import com.ivy.core.persistence.api.saving.SavingGoalRecordRead +import com.ivy.core.persistence.api.tag.TagRead +import com.ivy.core.persistence.api.transaction.TransactionRead +import java.util.* +import javax.inject.Inject + +class IvyWalletDataFromPartialAct @Inject constructor( + private val accountRead: AccountRead, + private val transactionRead: TransactionRead, + private val categoryRead: CategoryRead, + private val tagRead: TagRead, + private val recurringRuleRead: RecurringRuleRead, + private val attachmentRead: AttachmentRead, + private val budgetRead: BudgetRead, + private val savingGoalRead: SavingGoalRead, + private val savingGoalRecordRead: SavingGoalRecordRead, +) : Action>() { + override suspend fun action(input: PartialIvyWalletData): Either = + Either.catch { + IvyWalletData( + accounts = fromPartial(input.accounts, accountRead, ::AccountId), + transactions = fromPartial(input.transactions, transactionRead, ::TransactionId), + categories = fromPartial(input.categories, categoryRead, ::CategoryId), + tags = fromPartial(input.tags, tagRead, ::TagId), + recurringRules = fromPartial( + input.recurringRules, recurringRuleRead, ::RecurringRuleId + ), + attachments = fromPartial(input.attachments, attachmentRead, ::AttachmentId), + budgets = fromPartial(input.budgets, budgetRead, ::BudgetId), + savingGoals = fromPartial(input.savingGoals, savingGoalRead, ::SavingGoalId), + savingGoalRecords = fromPartial( + input.savingGoalRecords, savingGoalRecordRead, ::SavingGoalRecordId + ), + ) + }.mapLeft(ActionError::IO) + + private suspend fun fromPartial( + partial: SyncData, + read: ReadSyncable, + mapId: (UUID) -> TID, + ): SyncData = SyncData( + items = read.byIds(partial.items.map { mapId(it.id.uuid) }), + deleted = partial.deleted, + ) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/api/action/read/PartialIvyWalletDataAct.kt b/core/domain/src/main/java/com/ivy/core/domain/api/action/read/PartialIvyWalletDataAct.kt new file mode 100644 index 0000000..3b0878d --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/api/action/read/PartialIvyWalletDataAct.kt @@ -0,0 +1,44 @@ +package com.ivy.core.domain.api.action.read + +import arrow.core.Either +import com.ivy.core.data.sync.PartialIvyWalletData +import com.ivy.core.domain.action.Action +import com.ivy.core.domain.api.data.ActionError +import com.ivy.core.domain.calculation.syncDataFrom +import com.ivy.core.persistence.api.account.AccountRead +import com.ivy.core.persistence.api.attachment.AttachmentRead +import com.ivy.core.persistence.api.budget.BudgetRead +import com.ivy.core.persistence.api.category.CategoryRead +import com.ivy.core.persistence.api.recurring.RecurringRuleRead +import com.ivy.core.persistence.api.saving.SavingGoalRead +import com.ivy.core.persistence.api.saving.SavingGoalRecordRead +import com.ivy.core.persistence.api.tag.TagRead +import com.ivy.core.persistence.api.transaction.TransactionRead +import javax.inject.Inject + +class PartialIvyWalletDataAct @Inject constructor( + private val accountRead: AccountRead, + private val transactionRead: TransactionRead, + private val categoryRead: CategoryRead, + private val tagRead: TagRead, + private val recurringRuleRead: RecurringRuleRead, + private val attachmentRead: AttachmentRead, + private val budgetRead: BudgetRead, + private val savingGoalRead: SavingGoalRead, + private val savingGoalRecordRead: SavingGoalRecordRead, +) : Action>() { + override suspend fun action(input: Unit): Either = + Either.catch { + PartialIvyWalletData( + accounts = syncDataFrom(accountRead.allPartial()), + transactions = syncDataFrom(transactionRead.allPartial()), + categories = syncDataFrom(categoryRead.allPartial()), + tags = syncDataFrom(tagRead.allPartial()), + recurringRules = syncDataFrom(recurringRuleRead.allPartial()), + attachments = syncDataFrom(attachmentRead.allPartial()), + budgets = syncDataFrom(budgetRead.allPartial()), + savingGoals = syncDataFrom(savingGoalRead.allPartial()), + savingGoalRecords = syncDataFrom(savingGoalRecordRead.allPartial()) + ) + }.mapLeft(ActionError::IO) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/api/action/read/PeriodDataFlow.kt b/core/domain/src/main/java/com/ivy/core/domain/api/action/read/PeriodDataFlow.kt new file mode 100644 index 0000000..01b3cc8 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/api/action/read/PeriodDataFlow.kt @@ -0,0 +1,101 @@ +package com.ivy.core.domain.api.action.read + +import com.ivy.core.data.Transaction +import com.ivy.core.data.calculation.ExchangeRates +import com.ivy.core.data.calculation.RawStats +import com.ivy.core.data.common.TimeRange +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.domain.api.data.period.Collapsable +import com.ivy.core.domain.calculation.history.* +import com.ivy.core.domain.calculation.history.data.* +import com.ivy.core.domain.pure.util.flattenLatest +import com.ivy.core.persistence.api.recurring.RecurringRuleQuery +import com.ivy.core.persistence.api.recurring.RecurringRuleRead +import com.ivy.core.persistence.api.transaction.TransactionQuery +import com.ivy.core.persistence.api.transaction.TransactionRead +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import java.util.* +import javax.inject.Inject + +interface MockedSelectedPeriodFlow { + operator fun invoke(): Flow +} + +interface MockedRatesFlow { + operator fun invoke(): Flow +} + +interface MockedCollapsedFlow { + operator fun invoke(): Flow> +} + +@OptIn(ExperimentalCoroutinesApi::class) +class PeriodDataFlow @Inject constructor( + private val selectedPeriodFlow: MockedSelectedPeriodFlow, + private val recurringRuleRead: RecurringRuleRead, + private val transactionRead: TransactionRead, + private val ratesFlow: MockedRatesFlow, + private val collapsedFlow: MockedCollapsedFlow, +) : FlowAction() { + sealed interface Input { + object All : Input + // TODO: Add by Category, by Account, etc + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun createFlow(input: Input): Flow = + selectedPeriodFlow().flatMapLatest(::periodDataFlow) + + private fun periodDataFlow( + period: TimeRange + ): Flow = combine( + rawDueFlow(period), + rawActualFlow(period) + ) { rawDue, (rawHistory, rawStats) -> + ratesFlow().flatMapLatest { rates -> + // exchanged + val (due, history, periodValues) = with(rates) { + Triple( + exchangeDue(rawDue), + exchangeHistory(rawHistory), + exchangeHistoryRawStats(rawStats) + ) + } + collapsedFlow().map { collapsed -> + PeriodData( + periodIncome = periodValues.income, + periodExpense = periodValues.expense, + transactionList = transactionList( + due = due, + history = history, + collapsed = collapsed + ) + ) + } + } + }.flattenLatest() + + private fun rawDueFlow( + period: TimeRange + ): Flow>> = + combine( + recurringRuleRead.many(RecurringRuleQuery.ForPeriod(period)), + transactionRead.many(TransactionQuery.ForPeriod(period, actual = false)) + ) { rules, exceptions -> + groupedDueTransactions(rules, exceptions, period) + } + + + private fun rawActualFlow( + period: TimeRange + ): Flow>, RawStats>> = + transactionRead.many( + TransactionQuery.ForPeriod(period, actual = true) + ).map { trns -> + groupHistoryTransactions(trns) to historyRawStats(trns) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/api/action/write/Modify.kt b/core/domain/src/main/java/com/ivy/core/domain/api/action/write/Modify.kt new file mode 100644 index 0000000..febf4b1 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/api/action/write/Modify.kt @@ -0,0 +1,23 @@ +package com.ivy.core.domain.api.action.write + +import arrow.core.NonEmptyList +import com.ivy.core.data.sync.Syncable +import com.ivy.core.data.sync.UniqueId + +sealed interface Modify { + data class Save( + val item: T, + ) : Modify + + data class SaveMany( + val items: NonEmptyList + ) : Modify + + data class Delete( + val id: TID, + ) : Modify + + data class DeleteMany( + val ids: NonEmptyList + ) : Modify +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/api/action/write/WriteAccountAct.kt b/core/domain/src/main/java/com/ivy/core/domain/api/action/write/WriteAccountAct.kt new file mode 100644 index 0000000..37b43d2 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/api/action/write/WriteAccountAct.kt @@ -0,0 +1,6 @@ +package com.ivy.core.domain.api.action.write + + +class WriteAccountAct { + // TODO: Implement +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/api/action/write/WriteTransactionAct.kt b/core/domain/src/main/java/com/ivy/core/domain/api/action/write/WriteTransactionAct.kt new file mode 100644 index 0000000..2781810 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/api/action/write/WriteTransactionAct.kt @@ -0,0 +1,80 @@ +package com.ivy.core.domain.api.action.write + +import arrow.core.Either +import arrow.core.NonEmptyList +import arrow.core.continuations.either +import arrow.core.right +import arrow.core.toNonEmptyListOrNull +import com.ivy.core.data.Transaction +import com.ivy.core.data.TransactionId +import com.ivy.core.data.calculation.AccountCache +import com.ivy.core.domain.action.Action +import com.ivy.core.domain.api.data.ActionError +import com.ivy.core.domain.calculation.account.cache.invalidCaches +import com.ivy.core.persistence.api.account.AccountCacheRead +import com.ivy.core.persistence.api.account.AccountCacheWrite +import com.ivy.core.persistence.api.data.PersistenceError +import com.ivy.core.persistence.api.transaction.TransactionQuery +import com.ivy.core.persistence.api.transaction.TransactionRead +import com.ivy.core.persistence.api.transaction.TransactionWrite +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +class WriteTransactionAct @Inject constructor( + private val transactionRead: TransactionRead, + private val transactionWrite: TransactionWrite, + private val accountCacheWrite: AccountCacheWrite, + private val accountCacheRead: AccountCacheRead +) : Action, Either>() { + override suspend fun action( + input: Modify + ): Either = when (input) { + is Modify.Save -> save(input.item) + is Modify.SaveMany -> saveMany(input.items) + is Modify.Delete -> delete(input.id) + is Modify.DeleteMany -> deleteMany(input.ids) + }.mapLeft { ActionError.IO(it.reason) } + + private suspend fun save(item: Transaction): Either = either { + val old = transactionRead.single(item.id).first() + val caches = accountCacheRead.all() + invalidateCaches(invalidCaches(caches, setOfNotNull(old, item))).bind() + transactionWrite.save(item).bind() + } + + private suspend fun saveMany( + items: NonEmptyList + ): Either = either { + val oldOnes = items.map(Transaction::id).let { + transactionRead.many(TransactionQuery.ByIds(it)).first() + } + val caches = accountCacheRead.all() + invalidateCaches(invalidCaches(caches, (oldOnes + items).toSet())).bind() + transactionWrite.saveMany(items).bind() + } + + private suspend fun delete(id: TransactionId): Either = either { + val item = transactionRead.single(id).first() + val caches = accountCacheRead.all() + invalidateCaches(invalidCaches(caches, setOfNotNull(item))).bind() + transactionWrite.delete(id).bind() + } + + private suspend fun deleteMany( + ids: NonEmptyList + ): Either = either { + val items = transactionRead.many(TransactionQuery.ByIds(ids)).first() + val caches = accountCacheRead.all() + invalidateCaches(invalidCaches(caches, items.toSet())).bind() + transactionWrite.deleteMany(ids).bind() + } + + private suspend fun invalidateCaches( + caches: Set, + ): Either { + return caches.map(AccountCache::accountId).toNonEmptyListOrNull() + ?.let { ids -> + accountCacheWrite.deleteMany(ids) + } ?: Unit.right() + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/api/data/ActionError.kt b/core/domain/src/main/java/com/ivy/core/domain/api/data/ActionError.kt new file mode 100644 index 0000000..354bf3b --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/api/data/ActionError.kt @@ -0,0 +1,7 @@ +package com.ivy.core.domain.api.data + +sealed interface ActionError { + val reason: Throwable + + data class IO(override val reason: Throwable) : ActionError +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/api/data/Stats.kt b/core/domain/src/main/java/com/ivy/core/domain/api/data/Stats.kt new file mode 100644 index 0000000..807c327 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/api/data/Stats.kt @@ -0,0 +1,11 @@ +package com.ivy.core.domain.api.data + +import com.ivy.core.data.common.NonNegativeInt +import com.ivy.core.data.common.Value + +data class Stats( + val income: Value, + val expense: Value, + val incomesCount: NonNegativeInt, + val expensesCount: NonNegativeInt, +) \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/api/data/period/Collapsable.kt b/core/domain/src/main/java/com/ivy/core/domain/api/data/period/Collapsable.kt new file mode 100644 index 0000000..e3c1534 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/api/data/period/Collapsable.kt @@ -0,0 +1,5 @@ +package com.ivy.core.domain.api.data.period + +interface Collapsable { + val sectionId: String +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/api/data/period/DateDivider.kt b/core/domain/src/main/java/com/ivy/core/domain/api/data/period/DateDivider.kt new file mode 100644 index 0000000..db0849c --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/api/data/period/DateDivider.kt @@ -0,0 +1,10 @@ +package com.ivy.core.domain.api.data.period + +import com.ivy.core.data.common.SignedValue +import java.time.LocalDate + +data class DateDivider( + val date: LocalDate, + val cashflow: SignedValue, + override val sectionId: String +) : Collapsable, TransactionListItem \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/api/data/period/DueDivider.kt b/core/domain/src/main/java/com/ivy/core/domain/api/data/period/DueDivider.kt new file mode 100644 index 0000000..d553947 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/api/data/period/DueDivider.kt @@ -0,0 +1,10 @@ +package com.ivy.core.domain.api.data.period + +import com.ivy.core.data.common.Value + +data class DueDivider( + val income: Value?, + val expense: Value?, + val type: DueDividerType, + override val sectionId: String +) : Collapsable, TransactionListItem \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/api/data/period/DueDividerType.kt b/core/domain/src/main/java/com/ivy/core/domain/api/data/period/DueDividerType.kt new file mode 100644 index 0000000..9df0782 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/api/data/period/DueDividerType.kt @@ -0,0 +1,5 @@ +package com.ivy.core.domain.api.data.period + +enum class DueDividerType { + Upcoming, Overdue +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/api/data/period/TransactionCard.kt b/core/domain/src/main/java/com/ivy/core/domain/api/data/period/TransactionCard.kt new file mode 100644 index 0000000..1746148 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/api/data/period/TransactionCard.kt @@ -0,0 +1,7 @@ +package com.ivy.core.domain.api.data.period + +import com.ivy.core.data.Transaction + +data class TransactionCard( + val transaction: Transaction, +) : TransactionListItem \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/api/data/period/TransactionListItem.kt b/core/domain/src/main/java/com/ivy/core/domain/api/data/period/TransactionListItem.kt new file mode 100644 index 0000000..72d395a --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/api/data/period/TransactionListItem.kt @@ -0,0 +1,3 @@ +package com.ivy.core.domain.api.data.period + +sealed interface TransactionListItem \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/calculation/Exchange.kt b/core/domain/src/main/java/com/ivy/core/domain/calculation/Exchange.kt new file mode 100644 index 0000000..478f059 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/calculation/Exchange.kt @@ -0,0 +1,82 @@ +package com.ivy.core.domain.calculation + +import arrow.core.Option +import arrow.core.continuations.option +import arrow.core.toOption +import com.ivy.core.data.calculation.ExchangeRates +import com.ivy.core.data.common.AssetCode +import com.ivy.core.data.common.NonNegativeDouble +import com.ivy.core.data.common.PositiveDouble +import com.ivy.core.data.common.toPositiveUnsafe + +/** + * Exchanges an [amount] in [from] asset to [to] asset. + * + * @return Some successfully exchanged amount or None + */ +suspend fun ExchangeRates.exchange( + amount: NonNegativeDouble, + from: AssetCode, + to: AssetCode +): Option = option { + if (from == to) return@option amount // no need to exchange + if (amount.value == 0.0) return@option amount // no need to exchange + + val rate = findRate(from, to).bind() + NonNegativeDouble.fromDoubleUnsafe(rate.value * amount.value) +} + +suspend fun ExchangeRates.findRate( + from: AssetCode, + to: AssetCode, +): Option { + fun rate(asset: AssetCode): Option = + rates[asset].toOption() + + return option { + if (from == to) return@option 1.0.toPositiveUnsafe() // no need to exchange + + when (base) { + from -> { + /* + Case: BGN -> EUR + base = from = "BGN" + to = "EUR" + 1 BGN (from) = 1 * 0.51 (rate to) = 0.51 (rate to) = 0.51 EUR + */ + rate(to).bind() + } + + to -> { + /* + Case: EUR -> BGN + base = to = "BGN" + from = "EUR" + + 1 EUR (from) = 1 / 0.51 (rate from) = 1.96 BGN (to) + */ + val rateBaseFrom = rate(from).bind() + PositiveDouble.fromDouble(1.0 / rateBaseFrom.value).bind() + } + + else -> { + /* + Case: EUR -> USD + base = "BGN" + from = "EUR" + to = "USD" + + 1 EUR = 1 / 0.51 (rate from) = 1.96 BGN + 1 USD = 1 / 0.56 (rate to) = 1.8 BGN + + 1 EUR = 1.96 BGN / 1.8 BGN = + = [1 / 0.51 (rate from)] / [1 / 0.56 (rate to)] = 1.08 USD + => 1 EUR = 0.56 (rate to) / 0.51 (rate from) = 1.08 USD + */ + val rateFrom = rate(from).bind() + val rateTo = rate(to).bind() + PositiveDouble.fromDouble(rateTo.value / rateFrom.value).bind() + } + } + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/calculation/IvyWalletData.kt b/core/domain/src/main/java/com/ivy/core/domain/calculation/IvyWalletData.kt new file mode 100644 index 0000000..f661b8b --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/calculation/IvyWalletData.kt @@ -0,0 +1,16 @@ +package com.ivy.core.domain.calculation + +import com.ivy.core.data.sync.SyncData +import com.ivy.core.data.sync.Syncable + +// TODO: Fix this, it's not type-safe! +/** + * @param syncables combined [Syncable.removed] and not-removed syncables + */ +inline fun syncDataFrom(syncables: List): SyncData { + val map = syncables.groupBy { it.removed } + return SyncData( + items = map[false]?.map { it as T } ?: emptyList(), // not deleted + deleted = map[true]?.toSet() ?: emptySet() + ) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/calculation/RawStats.kt b/core/domain/src/main/java/com/ivy/core/domain/calculation/RawStats.kt new file mode 100644 index 0000000..b6fb8ef --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/calculation/RawStats.kt @@ -0,0 +1,102 @@ +package com.ivy.core.domain.calculation + +import com.ivy.core.data.calculation.RawStats +import com.ivy.core.data.common.AssetCode +import com.ivy.core.data.common.NonNegativeInt +import com.ivy.core.data.common.PositiveDouble +import com.ivy.core.data.common.toNonNegativeUnsafe +import com.ivy.core.data.optimized.LedgerEntry +import java.time.LocalDateTime + +/** + * We use Imperative style because this operation is performance-critical for the app. + */ +fun rawStats( + entries: List, + interpretTransfer: (LedgerEntry.Transfer) -> List +): RawStats { + val incomes = mutableMapOf() + val expenses = mutableMapOf() + var incomesCount = 0 + var expensesCount = 0 + var newestTrnTime = LocalDateTime.MIN + + fun updateNewestTime(entry: LedgerEntry) { + if (entry.time > newestTrnTime) { + newestTrnTime = entry.time + } + } + + fun processIncome(income: LedgerEntry.Single.Income) { + incomesCount++ + incomes.aggregate(income.value.asset, income.value.amount) + updateNewestTime(income) + } + + fun processExpense(expense: LedgerEntry.Single.Expense) { + expensesCount++ + expenses.aggregate(expense.value.asset, expense.value.amount) + updateNewestTime(expense) + } + + entries.forEach { entry -> + when (entry) { + is LedgerEntry.Single.Expense -> processExpense(entry) + is LedgerEntry.Single.Income -> processIncome(entry) + is LedgerEntry.Transfer -> interpretTransfer(entry).forEach { + when (it) { + is LedgerEntry.Single.Expense -> processExpense(it) + is LedgerEntry.Single.Income -> processIncome(it) + } + } + } + } + + return RawStats( + incomes = incomes.mapValues { PositiveDouble.fromDoubleUnsafe(it.value) }, + expenses = expenses.mapValues { PositiveDouble.fromDoubleUnsafe(it.value) }, + incomesCount = incomesCount.toNonNegativeUnsafe(), + expensesCount = expensesCount.toNonNegativeUnsafe(), + newestTransaction = newestTrnTime, + ) +} + +/** + * Sums all values in two [RawStats] instances. + * + * @return RawStats picking the largest newestTrnTime + * + * Complexity: + * **O(m+n) space-time** + * where: + * - m = Left's RawStats incomes & expenses maps size + * - n = Right's RawStats incomes & expenses maps size + */ +infix operator fun RawStats.plus(other: RawStats): RawStats { + fun sumMaps( + map1: Map, + other: Map, + ): Map { + val sum = mutableMapOf() + map1.forEach(sum::aggregate) + other.forEach(sum::aggregate) + return sum.mapValues { PositiveDouble.fromDoubleUnsafe(it.value) } + } + + return RawStats( + incomes = sumMaps(incomes, other.incomes), + expenses = sumMaps(expenses, other.expenses), + incomesCount = NonNegativeInt.fromIntUnsafe(incomesCount.value + other.incomesCount.value), + expensesCount = NonNegativeInt.fromIntUnsafe(expensesCount.value + other.expensesCount.value), + newestTransaction = maxOf(newestTransaction, other.newestTransaction) + ) +} + +private fun MutableMap.aggregate( + key: AssetCode, + amount: PositiveDouble, +) { + compute(key) { _, currentAmount -> + (currentAmount ?: 0.0) + amount.value + } +} diff --git a/core/domain/src/main/java/com/ivy/core/domain/calculation/account/AccountRawStats.kt b/core/domain/src/main/java/com/ivy/core/domain/calculation/account/AccountRawStats.kt new file mode 100644 index 0000000..fbf64e0 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/calculation/account/AccountRawStats.kt @@ -0,0 +1,29 @@ +package com.ivy.core.domain.calculation.account + +import com.ivy.core.data.AccountId +import com.ivy.core.data.calculation.RawStats +import com.ivy.core.data.optimized.LedgerEntry +import com.ivy.core.domain.calculation.rawStats + +/** + * @param entries all entries for the account including transfers (from - to) + */ +fun accountRawStats( + account: AccountId, + entries: List +): RawStats = rawStats( + entries +) { (from, to, time) -> + buildList { + if (from.account == account) { + // transfer going out of the account + // are interpreted as expenses + add(LedgerEntry.Single.Expense(from.value, time)) + } + if (to.account == account) { + // transfer going in the account + // are interpreted as incomes + add(LedgerEntry.Single.Income(to.value, time)) + } + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/calculation/account/cache/AccountCache.kt b/core/domain/src/main/java/com/ivy/core/domain/calculation/account/cache/AccountCache.kt new file mode 100644 index 0000000..8876b65 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/calculation/account/cache/AccountCache.kt @@ -0,0 +1,44 @@ +package com.ivy.core.domain.calculation.account.cache + +import com.ivy.core.data.AccountId +import com.ivy.core.data.Transaction +import com.ivy.core.data.TransactionTime +import com.ivy.core.data.calculation.AccountCache +import com.ivy.core.data.calculation.RawStats +import com.ivy.core.data.optimized.LedgerEntry +import com.ivy.core.domain.calculation.account.accountRawStats +import com.ivy.core.domain.calculation.plus + +fun accountRawStatsWithCache( + cache: AccountCache, + entriesAfterCache: List +): RawStats = cache.rawStats + accountRawStats(cache.accountId, entriesAfterCache) + +fun invalidCaches( + caches: List, + changedTransactions: Set +): Set { + // TODO: Check if this can be optimized + return caches.filter { cache -> + becomesInvalid(cache, changedTransactions) + }.toSet() +} + +private fun becomesInvalid( + cache: AccountCache, + transactions: Set +): Boolean { + val cacheAccount = cache.accountId + val cacheTime = cache.rawStats.newestTransaction + return transactions.any { trn -> + cacheAccount in affectedAccounts(trn) && + (trn.time is TransactionTime.Actual) && cacheTime >= trn.time.time + } +} + +private fun affectedAccounts(transaction: Transaction): Set = + when (transaction) { + is Transaction.Expense -> setOf(transaction.account) + is Transaction.Income -> setOf(transaction.account) + is Transaction.Transfer -> setOf(transaction.from.account, transaction.to.account) + } \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/calculation/history/History.kt b/core/domain/src/main/java/com/ivy/core/domain/calculation/history/History.kt new file mode 100644 index 0000000..2c9c7e3 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/calculation/history/History.kt @@ -0,0 +1,17 @@ +package com.ivy.core.domain.calculation.history + +import com.ivy.core.data.Transaction +import com.ivy.core.data.calculation.ExchangeRates +import com.ivy.core.domain.api.data.period.DateDivider +import com.ivy.core.domain.calculation.history.data.RawDateDivider +import com.ivy.core.domain.calculation.history.data.Sorted +import java.util.* + +fun groupHistoryTransactions( + transactions: List +): SortedMap> = TODO() + +context(ExchangeRates) +fun exchangeHistory( + rawMap: SortedMap> +): SortedMap> = TODO() \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/calculation/history/PeriodIncomeExpense.kt b/core/domain/src/main/java/com/ivy/core/domain/calculation/history/PeriodIncomeExpense.kt new file mode 100644 index 0000000..797dde0 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/calculation/history/PeriodIncomeExpense.kt @@ -0,0 +1,20 @@ +package com.ivy.core.domain.calculation.history + +import com.ivy.core.data.Transaction +import com.ivy.core.data.calculation.ExchangeRates +import com.ivy.core.data.calculation.RawStats +import com.ivy.core.data.common.Value + +fun historyRawStats( + transactions: List +): RawStats = TODO() + +data class PeriodIncomeExpense( + val income: Value, + val expense: Value +) + +context(ExchangeRates) +fun exchangeHistoryRawStats( + historyStats: RawStats +): PeriodIncomeExpense = TODO() \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/calculation/history/TransactionList.kt b/core/domain/src/main/java/com/ivy/core/domain/calculation/history/TransactionList.kt new file mode 100644 index 0000000..874b427 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/calculation/history/TransactionList.kt @@ -0,0 +1,15 @@ +package com.ivy.core.domain.calculation.history + +import com.ivy.core.data.Transaction +import com.ivy.core.domain.api.data.period.Collapsable +import com.ivy.core.domain.api.data.period.DateDivider +import com.ivy.core.domain.api.data.period.DueDivider +import com.ivy.core.domain.api.data.period.TransactionListItem +import com.ivy.core.domain.calculation.history.data.Sorted +import java.util.* + +fun transactionList( + due: SortedMap>, + history: SortedMap>, + collapsed: Set +): List = TODO() \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/calculation/history/UpcomingOverdue.kt b/core/domain/src/main/java/com/ivy/core/domain/calculation/history/UpcomingOverdue.kt new file mode 100644 index 0000000..f77f8cc --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/calculation/history/UpcomingOverdue.kt @@ -0,0 +1,43 @@ +package com.ivy.core.domain.calculation.history + +import com.ivy.core.data.RecurringRule +import com.ivy.core.data.Transaction +import com.ivy.core.data.calculation.ExchangeRates +import com.ivy.core.data.common.TimeRange +import com.ivy.core.domain.api.data.period.DueDivider +import com.ivy.core.domain.calculation.history.data.RawDueDivider +import com.ivy.core.domain.calculation.history.data.Sorted +import com.ivy.core.domain.calculation.recurring.generateRecurring +import java.util.* + +fun groupedDueTransactions( + rules: List, + dueTransactions: List, + period: TimeRange, +): SortedMap> = groupDueTransactions( + generateDueTransactions(rules, dueTransactions, period) +) + +private fun generateDueTransactions( + rules: List, + dueTransactions: List, + period: TimeRange, +): List { + val dueByRule = dueTransactions.groupBy { it.recurring } + return rules.flatMap { + generateRecurring( + rule = it, + ruleExceptions = dueByRule[it.id] ?: emptyList(), + period = period, + ) + } +} + +private fun groupDueTransactions( + dueTransactions: List +): SortedMap> = TODO() + +context(ExchangeRates) +fun exchangeDue( + rawMap: SortedMap> +): SortedMap> = TODO() \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/calculation/history/data/PeriodData.kt b/core/domain/src/main/java/com/ivy/core/domain/calculation/history/data/PeriodData.kt new file mode 100644 index 0000000..5dc8f67 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/calculation/history/data/PeriodData.kt @@ -0,0 +1,10 @@ +package com.ivy.core.domain.calculation.history.data + +import com.ivy.core.data.common.Value +import com.ivy.core.domain.api.data.period.TransactionListItem + +data class PeriodData( + val periodIncome: Value, + val periodExpense: Value, + val transactionList: List +) \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/calculation/history/data/RawDateDivider.kt b/core/domain/src/main/java/com/ivy/core/domain/calculation/history/data/RawDateDivider.kt new file mode 100644 index 0000000..9009ba2 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/calculation/history/data/RawDateDivider.kt @@ -0,0 +1,11 @@ +package com.ivy.core.domain.calculation.history.data + +import com.ivy.core.data.calculation.RawStats +import com.ivy.core.domain.api.data.period.Collapsable +import java.time.LocalDate + +data class RawDateDivider( + val date: LocalDate, + val stats: RawStats, + override val sectionId: String +) : Collapsable \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/calculation/history/data/RawDueDivider.kt b/core/domain/src/main/java/com/ivy/core/domain/calculation/history/data/RawDueDivider.kt new file mode 100644 index 0000000..8fd490f --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/calculation/history/data/RawDueDivider.kt @@ -0,0 +1,11 @@ +package com.ivy.core.domain.calculation.history.data + +import com.ivy.core.data.calculation.RawStats +import com.ivy.core.domain.api.data.period.Collapsable +import com.ivy.core.domain.api.data.period.DueDividerType + +data class RawDueDivider( + val stats: RawStats, + val type: DueDividerType, + override val sectionId: String +) : Collapsable \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/calculation/history/data/Sorted.kt b/core/domain/src/main/java/com/ivy/core/domain/calculation/history/data/Sorted.kt new file mode 100644 index 0000000..a7d2bab --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/calculation/history/data/Sorted.kt @@ -0,0 +1,6 @@ +package com.ivy.core.domain.calculation.history.data + +@JvmInline +value class Sorted( + val items: List +) \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/calculation/recurring/RecurringTransactions.kt b/core/domain/src/main/java/com/ivy/core/domain/calculation/recurring/RecurringTransactions.kt new file mode 100644 index 0000000..b6c945e --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/calculation/recurring/RecurringTransactions.kt @@ -0,0 +1,12 @@ +package com.ivy.core.domain.calculation.recurring + +import com.ivy.core.data.RecurringRule +import com.ivy.core.data.Transaction +import com.ivy.core.data.common.TimeRange + + +fun generateRecurring( + rule: RecurringRule, + ruleExceptions: List, + period: TimeRange, +): List = TODO() \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/di/DomainModuleDI.kt b/core/domain/src/main/java/com/ivy/core/domain/di/DomainModuleDI.kt new file mode 100644 index 0000000..5855cf2 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/di/DomainModuleDI.kt @@ -0,0 +1,10 @@ +package com.ivy.core.domain.di + +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object DomainModuleDI { +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/Currency.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/Currency.kt new file mode 100644 index 0000000..14ff36a --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/Currency.kt @@ -0,0 +1,17 @@ +package com.ivy.core.domain.pure + +import android.icu.util.Currency +import com.ivy.data.CurrencyCode +import com.ivy.data.IvyCurrency +import java.util.* + + +fun getDefaultCurrency(): CurrencyCode = + (Currency.getInstance(Locale.getDefault()) ?: Currency.getInstance("USD") + ?: Currency.getInstance("usd") ?: Currency.getAvailableCurrencies().firstOrNull() + ?: Currency.getInstance("EUR")).currencyCode + +fun isFiat(currency: CurrencyCode): Boolean = !isCrypto(currency) + +fun isCrypto(currency: CurrencyCode): Boolean = + IvyCurrency.CRYPTO.map { it.code }.contains(currency) \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/account/AdjustBalance.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/account/AdjustBalance.kt new file mode 100644 index 0000000..b400eb2 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/account/AdjustBalance.kt @@ -0,0 +1,51 @@ +package com.ivy.core.domain.pure.account + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.domain.pure.isFiat +import com.ivy.core.domain.pure.util.isInsignificant +import com.ivy.data.Sync +import com.ivy.data.SyncState +import com.ivy.data.Value +import com.ivy.data.account.Account +import com.ivy.data.transaction.* +import java.util.* +import kotlin.math.abs + +fun adjustBalanceTrn( + timeProvider: TimeProvider, + account: Account, + currentBalance: Double, + desiredBalance: Double, + hiddenTrn: Boolean, +): Transaction? { + // if the acc has 50$ and we want to adjust it to 40$ + // => we need to create an Expense for $10 + val amountMissing = currentBalance - desiredBalance + + if (isFiat(account.currency) && isInsignificant(amountMissing)) { + // Balance diff is insignificant less than 1 "penny" (0.01) + // no need to adjust + return null + } + + return Transaction( + id = UUID.randomUUID(), + account = account, + category = null, // unspecified + type = if (amountMissing > 0) TransactionType.Expense else TransactionType.Income, + value = Value(amount = abs(amountMissing), currency = account.currency), + title = "Adjust balance", + description = null, + time = TrnTime.Actual(timeProvider.timeNow()), + state = if (hiddenTrn) TrnState.Hidden else TrnState.Default, + purpose = TrnPurpose.AdjustBalance, + + attachments = emptyList(), + sync = Sync( + state = SyncState.Syncing, + lastUpdated = timeProvider.timeNow() + ), + tags = emptyList(), + metadata = TrnMetadata(recurringRuleId = null, loanId = null, loanRecordId = null), + ) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/account/ValidateAccount.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/account/ValidateAccount.kt new file mode 100644 index 0000000..091948c --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/account/ValidateAccount.kt @@ -0,0 +1,8 @@ +package com.ivy.core.domain.pure.account + +import com.ivy.data.account.Account + +fun validateAccount(account: Account): Boolean { + if (account.name.isBlank()) return false + return true +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/calculate/FilterTrnansactions.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/calculate/FilterTrnansactions.kt new file mode 100644 index 0000000..434cfa3 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/calculate/FilterTrnansactions.kt @@ -0,0 +1,22 @@ +package com.ivy.core.domain.pure.calculate + +import com.ivy.data.transaction.Transaction +import com.ivy.data.transaction.TrnPurpose +import com.ivy.data.transaction.TrnState + +fun List.filter( + includeTransfers: Boolean, + includeHidden: Boolean, +): List = this.filter { + it.transferFilter(includeTransfers = includeTransfers) && + it.hiddenFilter(includeHidden = includeHidden) +} + +private fun Transaction.transferFilter(includeTransfers: Boolean): Boolean = + includeTransfers || when (purpose) { + TrnPurpose.TransferFrom, TrnPurpose.TransferTo -> false + else -> true + } + +private fun Transaction.hiddenFilter(includeHidden: Boolean): Boolean = + includeHidden || state != TrnState.Hidden \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/dummy/DummyAccount.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/dummy/DummyAccount.kt new file mode 100644 index 0000000..bf2f0e8 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/dummy/DummyAccount.kt @@ -0,0 +1,37 @@ +package com.ivy.core.domain.pure.dummy + +import androidx.annotation.ColorInt +import com.ivy.data.CurrencyCode +import com.ivy.data.ItemIconId +import com.ivy.data.Sync +import com.ivy.data.SyncState +import com.ivy.data.account.Account +import com.ivy.data.account.AccountState +import java.time.LocalDateTime +import java.util.* + +fun dummyAcc( + id: UUID = UUID.randomUUID(), + name: String = "Dummy acc", + currency: CurrencyCode = "USD", + @ColorInt + color: Int = 1, + icon: ItemIconId = "account", + folderId: UUID? = null, + excluded: Boolean = false, + orderNum: Double = 0.0, + sync: SyncState = SyncState.Synced, + lastUpdated: LocalDateTime = LocalDateTime.now(), + state: AccountState = AccountState.Default, +): Account = Account( + id = id, + name = name, + currency = currency, + color = color, + icon = icon, + folderId = folderId, + excluded = excluded, + orderNum = orderNum, + sync = Sync(sync, lastUpdated), + state = state, +) diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/dummy/DummyCategory.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/dummy/DummyCategory.kt new file mode 100644 index 0000000..8d25356 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/dummy/DummyCategory.kt @@ -0,0 +1,35 @@ +package com.ivy.core.domain.pure.dummy + +import androidx.annotation.ColorInt +import com.ivy.data.ItemIconId +import com.ivy.data.Sync +import com.ivy.data.SyncState +import com.ivy.data.category.Category +import com.ivy.data.category.CategoryState +import com.ivy.data.category.CategoryType +import java.time.LocalDateTime +import java.util.* + +fun dummyCategory( + id: UUID = UUID.randomUUID(), + name: String = "Dummy Category", + parentCategoryId: UUID? = null, + @ColorInt + color: Int = 1, + icon: ItemIconId = "category", + sync: SyncState = SyncState.Synced, + orderNum: Double = 0.0, + type: CategoryType = CategoryType.Both, + state: CategoryState = CategoryState.Default, + lastUpdated: LocalDateTime = LocalDateTime.now(), +): Category = Category( + id = id, + name = name, + parentCategoryId = parentCategoryId, + color = color, + icon = icon, + sync = Sync(sync, lastUpdated), + orderNum = orderNum, + type = type, + state = state, +) diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/dummy/DummyTrn.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/dummy/DummyTrn.kt new file mode 100644 index 0000000..cb00223 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/dummy/DummyTrn.kt @@ -0,0 +1,79 @@ +package com.ivy.core.domain.pure.dummy + +import com.ivy.common.time.timeNow +import com.ivy.data.CurrencyCode +import com.ivy.data.Sync +import com.ivy.data.SyncState +import com.ivy.data.Value +import com.ivy.data.account.Account +import com.ivy.data.attachment.Attachment +import com.ivy.data.category.Category +import com.ivy.data.tag.Tag +import com.ivy.data.transaction.* +import java.time.LocalDateTime +import java.util.* + +fun dummyTrn( + id: UUID = UUID.randomUUID(), + account: Account = dummyAcc(), + type: TransactionType = TransactionType.Income, + amount: Double = 0.0, + currency: CurrencyCode? = null, + category: Category? = dummyCategory(), + time: TrnTime = TrnTime.Actual(timeNow()), + title: String? = "Dummy trn", + description: String? = null, + tags: List = emptyList(), + attachments: List = emptyList(), + metadata: TrnMetadata = dummyTrnMetadata(), + state: TrnState = TrnState.Default, + purpose: TrnPurpose? = null, + sync: SyncState = SyncState.Synced, + lastUpdated: LocalDateTime = LocalDateTime.now(), +): Transaction = Transaction( + id = id, + account = account, + type = type, + value = Value( + amount = amount, + currency = currency ?: account.currency, + ), + category = category, + time = time, + title = title, + description = description, + metadata = metadata, + state = state, + purpose = purpose, + sync = Sync(sync, lastUpdated), + tags = tags, + attachments = attachments, +) + +fun dummyValue( + amount: Double = 0.0, + currency: CurrencyCode = "USD" +): Value = Value( + amount = amount, + currency = currency, +) + + +fun dummyActual( + time: LocalDateTime = timeNow() +): TrnTime.Actual = TrnTime.Actual(time) + + +fun dummyDue( + time: LocalDateTime = timeNow() +): TrnTime.Due = TrnTime.Due(time) + +fun dummyTrnMetadata( + recurringRuleId: UUID? = null, + loanId: UUID? = null, + loanRecordId: UUID? = null, +) = TrnMetadata( + recurringRuleId = recurringRuleId, + loanId = loanId, + loanRecordId = loanRecordId +) \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/exchange/Exchange.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/exchange/Exchange.kt new file mode 100644 index 0000000..39eb993 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/exchange/Exchange.kt @@ -0,0 +1,97 @@ +package com.ivy.core.domain.pure.exchange + +import arrow.core.* +import arrow.core.computations.option +import com.ivy.data.CurrencyCode +import com.ivy.data.exchange.ExchangeRates + +/** + * @return the successfully exchanged amount or the amount as it was if the rate was missing + */ +@JvmName("exchangeExt") +suspend fun ExchangeRates.exchange( + from: CurrencyCode, + to: CurrencyCode, + amount: Double +): Double = exchange( + exchangeData = this, + from = from, + to = to, + amount = amount +).getOrElse { amount } + +suspend fun exchange( + exchangeData: ExchangeRates, + from: CurrencyCode, + to: CurrencyCode, + amount: Double, +): Option = option { + if (from == to) return@option amount + if (amount == 0.0) return@option 0.0 + + val rate = findRate( + ratesData = exchangeData, + from = from, + to = to, + ).bind() + + rate * amount +} + +suspend fun findRate( + ratesData: ExchangeRates, + from: CurrencyCode, + to: CurrencyCode, +): Option = option { + val fromCurrency = from.validateCurrency().bind() + val toCurrency = to.validateCurrency().bind() + + if (fromCurrency == toCurrency) return@option 1.0 + + val rates = ratesData.rates + + when (ratesData.baseCurrency) { + fromCurrency -> { + // exchange from base currency to other currency + //w e need the rate from baseCurrency to toCurrency + rates[toCurrency].validateRate().bind() + //toAmount = fromAmount * rateFromTo + } + toCurrency -> { + // exchange from other currency to base currency + // we'll get the rate to + + /* + Example: fromA = 10 fromC = EUR; toC = BGN + rateToFrom = rate (BGN EUR) ~= 0.51 + + Formula: (10 EUR / 0.51 ~= 19.67) + fromAmount / rateToFrom + + EXPECTED: 10 EUR ~= 19.67 BGN + */ + 1.0 / rates[fromCurrency].validateRate().bind() + } + else -> { + //exchange from other currency to other currency + //that's the only possible case left because we already checked "fromCurrency == toCurrency" + + val rateBaseFrom = rates[fromCurrency].validateRate().bind() + val rateBaseTo = rates[toCurrency].validateRate().bind() + + //Convert: toBaseCurrency -> toToCurrency + val rateBase = 1 / rateBaseFrom + rateBase * rateBaseTo + } + } +} + +private fun String.validateCurrency(): Option { + return if (this.isNotBlank()) return Some(this) else None +} + +fun Double?.validateRate(): Option { + val rate = this ?: return None + //exchange rate which <= 0 is invalid! + return if (rate > 0) return Some(this) else None +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/format/CombinedValueUi.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/format/CombinedValueUi.kt new file mode 100644 index 0000000..4a83d83 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/format/CombinedValueUi.kt @@ -0,0 +1,44 @@ +package com.ivy.core.domain.pure.format + +import androidx.compose.runtime.Immutable +import com.ivy.data.Value + +@Immutable +data class CombinedValueUi constructor( + val value: Value, + val valueUi: ValueUi, +) { + companion object { + fun initial() = CombinedValueUi( + value = Value(amount = 0.0, currency = ""), + valueUi = ValueUi(amount = "0.0", currency = ""), + ) + } + + constructor( + amount: Double, + currency: String, + shortenFiat: Boolean, + ) : this( + value = Value(amount, currency), + shortenFiat = shortenFiat, + ) + + constructor( + value: Value, + shortenFiat: Boolean, + ) : this( + value = value, + valueUi = format(value, shortenFiat = shortenFiat), + ) +} + +fun dummyCombinedValueUi( + amount: Double = 0.0, + currency: String = "USD", + shortenFiat: Boolean = false, +) = CombinedValueUi( + amount = amount, + currency = currency, + shortenFiat = shortenFiat, +) \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/format/FormatValue.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/format/FormatValue.kt new file mode 100644 index 0000000..2f9c681 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/format/FormatValue.kt @@ -0,0 +1,37 @@ +package com.ivy.core.domain.pure.format + +import com.ivy.core.domain.pure.isCrypto +import com.ivy.core.domain.pure.util.formatShortened +import com.ivy.data.Value +import java.text.DecimalFormat + +fun format( + value: Value, + shortenFiat: Boolean, +): ValueUi = if (isCrypto(value.currency)) + formatCrypto(value) else formatFiat(value = value, shorten = shortenFiat) + +private fun formatCrypto(value: Value): ValueUi { + val df = DecimalFormat("###,###,##0.${"#".repeat(16)}") + return ValueUi( + amount = df.format(value.amount), + currency = value.currency + ) +} + +private fun formatFiat( + value: Value, + shorten: Boolean +): ValueUi = if (shorten) { + // shorten to 10k, 10M, etc + ValueUi( + amount = formatShortened(value.amount), + currency = value.currency + ) +} else { + val df = DecimalFormat("###,##0.##") + ValueUi( + amount = df.format(value.amount), + currency = value.currency + ) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/format/ValueUi.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/format/ValueUi.kt new file mode 100644 index 0000000..13ce052 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/format/ValueUi.kt @@ -0,0 +1,30 @@ +package com.ivy.core.domain.pure.format + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface SignedValueUi { + val value: ValueUi + + @Immutable + data class Positive(override val value: ValueUi) : SignedValueUi + + @Immutable + data class Zero(override val value: ValueUi) : SignedValueUi + + @Immutable + data class Negative(override val value: ValueUi) : SignedValueUi +} + +@Immutable +data class ValueUi( + val amount: String, + val currency: String, +) + +fun dummyValueUi( + amount: String = "0", + currency: String = "USD" +) = ValueUi( + amount = amount, currency = currency, +) \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/mapping/entity/AccountEntityMapping.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/mapping/entity/AccountEntityMapping.kt new file mode 100644 index 0000000..75d3110 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/mapping/entity/AccountEntityMapping.kt @@ -0,0 +1,25 @@ +package com.ivy.core.domain.pure.mapping.entity + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.toUtc +import com.ivy.core.persistence.entity.account.AccountEntity +import com.ivy.data.account.Account + +fun mapToEntity( + acc: Account, + timeProvider: TimeProvider, +): AccountEntity = with(acc) { + AccountEntity( + id = id.toString(), + name = name, + currency = currency, + color = color, + icon = icon, + folderId = folderId?.toString(), + orderNum = orderNum, + excluded = excluded, + state = state, + sync = sync.state, + lastUpdated = sync.lastUpdated.toUtc(timeProvider), + ) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/mapping/entity/AttachmentEntityMapping.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/mapping/entity/AttachmentEntityMapping.kt new file mode 100644 index 0000000..1887c1d --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/mapping/entity/AttachmentEntityMapping.kt @@ -0,0 +1,22 @@ +package com.ivy.core.domain.pure.mapping.entity + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.toUtc +import com.ivy.core.persistence.entity.attachment.AttachmentEntity +import com.ivy.data.attachment.Attachment + +fun mapToEntity( + attachment: Attachment, + timeProvider: TimeProvider, +): AttachmentEntity = with(attachment) { + AttachmentEntity( + id = id, + associatedId = associatedId, + uri = uri, + source = source, + filename = filename, + type = type, + sync = sync.state, + lastUpdated = sync.lastUpdated.toUtc(timeProvider), + ) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/mapping/entity/CategoryEntityMapping.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/mapping/entity/CategoryEntityMapping.kt new file mode 100644 index 0000000..f80d445 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/mapping/entity/CategoryEntityMapping.kt @@ -0,0 +1,21 @@ +package com.ivy.core.domain.pure.mapping.entity + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.toUtc +import com.ivy.core.persistence.entity.category.CategoryEntity +import com.ivy.data.category.Category + +fun mapToEntity(category: Category, timeProvider: TimeProvider) = with(category) { + CategoryEntity( + id = id.toString(), + name = name, + color = color, + icon = icon, + orderNum = orderNum, + parentCategoryId = parentCategoryId?.toString(), + state = state, + type = type, + sync = sync.state, + lastUpdated = sync.lastUpdated.toUtc(timeProvider), + ) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/mapping/entity/TrnEntityMapping.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/mapping/entity/TrnEntityMapping.kt new file mode 100644 index 0000000..86d7be2 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/mapping/entity/TrnEntityMapping.kt @@ -0,0 +1,31 @@ +package com.ivy.core.domain.pure.mapping.entity + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.time +import com.ivy.common.time.toUtc +import com.ivy.core.persistence.entity.trn.TransactionEntity +import com.ivy.core.persistence.entity.trn.data.TrnTimeType +import com.ivy.data.transaction.Transaction +import com.ivy.data.transaction.TrnTime + +fun mapToEntity( + trn: Transaction, + timeProvider: TimeProvider, +) = with(trn) { + TransactionEntity( + id = id.toString(), + accountId = account.id.toString(), + type = type, + state = state, + purpose = purpose, + currency = value.currency, + amount = value.amount, + categoryId = category?.id?.toString(), + title = title, + description = description, + time = time.time().toUtc(timeProvider), + timeType = if (time is TrnTime.Actual) TrnTimeType.Actual else TrnTimeType.Due, + sync = sync.state, + lastUpdated = sync.lastUpdated.toUtc(timeProvider), + ) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/mapping/entity/TrnTagEntityMapping.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/mapping/entity/TrnTagEntityMapping.kt new file mode 100644 index 0000000..10cf569 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/mapping/entity/TrnTagEntityMapping.kt @@ -0,0 +1,18 @@ +package com.ivy.core.domain.pure.mapping.entity + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.toUtc +import com.ivy.core.persistence.entity.trn.TrnTagEntity +import com.ivy.data.Sync + +fun mapToTrnTagEntity( + trnId: String, + tagId: String, + sync: Sync, + timeProvider: TimeProvider, +): TrnTagEntity = TrnTagEntity( + trnId = trnId, + tagId = tagId, + sync = sync.state, + lastUpdated = sync.lastUpdated.toUtc(timeProvider) +) \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/time/DynamicTimePeriod.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/time/DynamicTimePeriod.kt new file mode 100644 index 0000000..faf5049 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/time/DynamicTimePeriod.kt @@ -0,0 +1,86 @@ +package com.ivy.core.domain.pure.time + +import com.ivy.common.time.* +import com.ivy.common.time.provider.TimeProvider +import com.ivy.data.time.DynamicTimePeriod +import com.ivy.data.time.TimeRange +import com.ivy.data.time.TimeUnit + +fun DynamicTimePeriod.toRange( + startDayOfMonth: Int, + timeProvider: TimeProvider +): TimeRange = when (this) { + is DynamicTimePeriod.Calendar -> toRange(startDayOfMonth, timeProvider) + is DynamicTimePeriod.Last -> toRange(timeProvider) + is DynamicTimePeriod.Next -> TODO() +} + +// region Calendar +fun DynamicTimePeriod.Calendar.toRange( + startDayOfMonth: Int, + timeProvider: TimeProvider, +): TimeRange { + val today = timeProvider.dateNow() + val offset = offset.toLong() + return when (unit) { + TimeUnit.Day -> today.plusDays(offset) + .let { + TimeRange( + from = it.atStartOfDay(), + to = it.atEndOfDay() + ) + } + TimeUnit.Week -> today.plusWeeks(offset).let { + TimeRange( + from = startOfWeek(it).atStartOfDay(), + to = endOfWeek(it).atEndOfDay() + ) + } + TimeUnit.Month -> monthlyTimeRange( + date = today.plusMonths(offset), startDayOfMonth = startDayOfMonth + ) + TimeUnit.Year -> today.plusYears(offset).let { + TimeRange( + from = startOfYear(it).atStartOfDay(), + to = endOfYear(it).atEndOfDay() + ) + } + } +} +// endregion + +// region Last +fun DynamicTimePeriod.Last.toRange( + timeProvider: TimeProvider, +): TimeRange { + val today = timeProvider.dateNow() + val adjustedN = n.toLong() - 1 // because it includes today + return TimeRange( + from = when (unit) { + TimeUnit.Day -> today.minusDays(adjustedN) + TimeUnit.Week -> today.minusWeeks(adjustedN) + TimeUnit.Month -> today.minusMonths(adjustedN) + TimeUnit.Year -> today.minusYears(adjustedN) + }.atStartOfDay(), + to = today.atEndOfDay() + ) +} +// endregion + +// region Next +fun DynamicTimePeriod.Next.toRange( + timeProvider: TimeProvider, +): TimeRange { + val today = timeProvider.dateNow() + val adjustedN = n.toLong() - 1 // because it includes today + return TimeRange( + from = today.atStartOfDay(), + to = when (unit) { + TimeUnit.Day -> today.plusDays(adjustedN) + TimeUnit.Week -> today.plusWeeks(adjustedN) + TimeUnit.Month -> today.plusMonths(adjustedN) + TimeUnit.Year -> today.plusYears(adjustedN) + }.atStartOfDay() + ) +} +// endregion \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/time/MonthlyPeriod.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/time/MonthlyPeriod.kt new file mode 100644 index 0000000..e5082a1 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/time/MonthlyPeriod.kt @@ -0,0 +1,73 @@ +package com.ivy.core.domain.pure.time + +import com.ivy.common.time.atEndOfDay +import com.ivy.common.time.endOfMonth +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.startOfMonth +import com.ivy.common.time.withDayOfMonthSafe +import com.ivy.data.time.Month +import com.ivy.data.time.SelectedPeriod +import com.ivy.data.time.TimeRange +import java.time.LocalDate + +// region Current monthly period +fun currentMonthlyPeriod( + startDayOfMonth: Int, + timeProvider: TimeProvider +): SelectedPeriod { + val today = timeProvider.dateNow() + + //Example: today = Nov (7), startDate = 7; + // Current period = from Nov (7) till Dec (6) + // => new period starts ony if "today => startDayOfMonth" + val newPeriodStarted = today.dayOfMonth >= startDayOfMonth + + val periodDate = if (newPeriodStarted) { + // new monthly period has already started then observe it => current month + today + } else { + // new monthly period hasn't yet started then observe the ongoing one => previous month + today.minusMonths(1) + } + + return monthlyPeriod( + dateInPeriod = periodDate, + startDayOfMonth = startDayOfMonth, + ) +} +// endregion + +// region Monthly period from date +fun monthlyPeriod( + dateInPeriod: LocalDate, + startDayOfMonth: Int, +): SelectedPeriod.Monthly = SelectedPeriod.Monthly( + month = Month( + number = dateInPeriod.monthValue, + year = dateInPeriod.year, + ), + startDayOfMonth = startDayOfMonth, + range = monthlyTimeRange(date = dateInPeriod, startDayOfMonth = startDayOfMonth), +) + +fun monthlyTimeRange(date: LocalDate, startDayOfMonth: Int): TimeRange = + if (startDayOfMonth != 1) { + val from = date + .withDayOfMonthSafe(startDayOfMonth) + .atStartOfDay() + + val to = date + .plusMonths(1) + .withDayOfMonthSafe(startDayOfMonth) + //e.g. Correct: 14.10-13.11 (Incorrect: 14.10-14.11) + .minusDays(1) + .atEndOfDay() + + TimeRange(from = from, to = to) + } else { + TimeRange( + from = startOfMonth(date).atStartOfDay(), + to = endOfMonth(date).atEndOfDay() + ) + } +// endregion \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/time/PeriodFunctions.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/time/PeriodFunctions.kt new file mode 100644 index 0000000..ee797b7 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/time/PeriodFunctions.kt @@ -0,0 +1,40 @@ +package com.ivy.core.domain.pure.time + +import com.ivy.common.time.atEndOfDay +import com.ivy.common.time.beginningOfIvyTime +import com.ivy.common.time.endOfIvyTime +import com.ivy.data.time.SelectedPeriod +import com.ivy.data.time.TimeRange +import com.ivy.data.time.TimeUnit +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneOffset + +fun allTime(): TimeRange = TimeRange( + from = beginningOfIvyTime(), + to = endOfIvyTime() +) + +fun shiftTime(time: LocalDateTime, n: Int, unit: TimeUnit): LocalDateTime { + val nLong = n.toLong() + return when (unit) { + TimeUnit.Day -> time.plusDays(nLong) + TimeUnit.Week -> time.plusWeeks(nLong) + TimeUnit.Month -> time.plusMonths(nLong) + TimeUnit.Year -> time.plusYears(nLong) + } +} + +fun periodLengthDays(range: TimeRange): Int { + val secondsDiff = range.to.toInstant(ZoneOffset.UTC).epochSecond - + range.from.toInstant(ZoneOffset.UTC).epochSecond + val daysLong = java.util.concurrent.TimeUnit.SECONDS.toDays(secondsDiff) + return daysLong.toInt() +} + +fun yearlyPeriod(year: Int): SelectedPeriod.CustomRange = SelectedPeriod.CustomRange( + range = TimeRange( + from = LocalDate.of(year, 1, 1).atStartOfDay(), + to = LocalDate.of(year, 12, 31).atEndOfDay() + ) +) diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/transaction/SumTransactions.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/transaction/SumTransactions.kt new file mode 100644 index 0000000..f2da9db --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/transaction/SumTransactions.kt @@ -0,0 +1,66 @@ +package com.ivy.core.domain.pure.transaction + +import arrow.core.NonEmptyList +import com.ivy.core.domain.pure.util.mapIndexedNel +import com.ivy.core.domain.pure.util.nonEmptyListOfZeros +import com.ivy.data.transaction.Transaction + + +/** + * Efficiently calculates a sum of transactions given [selectors]. + * + * @param transactions list of transactions to sum. + * @param selectors a list of selector functions + * transforming a transaction and [Arg] into [Double]. + * **Tip:** Use @Suppress("RedundantSuspendModifier", "UNUSED_PARAMETER") for + * selector functions that doesn't use the [Arg] or aren't suspend. + * @param arg argument to the passed to [selectors]. + * @return a list of sums corresponding to each [selectors] resulting sum. + * + * + * ``` + * + * + * // Example: + * + * @Suppress("RedundantSuspendModifier", "UNUSED_PARAMETER") + * suspend fun income(trn: Transaction, arg: Unit) = when(trn.type) { + * TrnType.Income -> trn.amount.value + * else -> 0.0 + * } + * + * @Suppress("RedundantSuspendModifier", "UNUSED_PARAMETER") + * suspend fun expense(trn: Transaction, arg: Unit) = when(trn.type) { + * TrnType.Expense -> trn.amount.value + * else -> 0.0 + * } + * + * val res = sumTransactions( + * transactions = trns, + * selectors = nonEmptyListOf( + * ::income, + * ::expense + * ), + * arg = Unit + * ) + * println("Income = $res[0]") + * println("Expense = $res[1]") + * ``` + */ +@Deprecated("inefficient - will be replaced with `account-cache` algo") +suspend fun sumTransactions( + transactions: List, + selectors: NonEmptyList Double>, + arg: Arg +): NonEmptyList { + var allSums = nonEmptyListOfZeros(n = selectors.size) + + for (trn in transactions) { + allSums = allSums.mapIndexedNel { index, sum -> + val valueFunction = selectors[index] + sum + valueFunction(trn, arg) + } + } + + return allSums +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/transaction/ValidateTransaction.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/transaction/ValidateTransaction.kt new file mode 100644 index 0000000..31e093d --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/transaction/ValidateTransaction.kt @@ -0,0 +1,8 @@ +package com.ivy.core.domain.pure.transaction + +import com.ivy.data.transaction.Transaction + +fun validateTransaction(trn: Transaction): Boolean { + if (trn.value.amount <= 0) return false + return true +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/transaction/transfer/ValidateTransfer.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/transaction/transfer/ValidateTransfer.kt new file mode 100644 index 0000000..f552bb7 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/transaction/transfer/ValidateTransfer.kt @@ -0,0 +1,11 @@ +package com.ivy.core.domain.pure.transaction.transfer + +import com.ivy.core.domain.action.transaction.transfer.TransferData + +fun validateTransfer(data: TransferData): Boolean { + if (data.accountFrom == data.accountTo) return false + if (data.amountFrom.amount <= 0) return false + if (data.amountTo.amount <= 0) return false + if (data.fee != null && data.fee.amount <= 0) return false + return true +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/ui/GroupByRows.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/ui/GroupByRows.kt new file mode 100644 index 0000000..dc0af7b --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/ui/GroupByRows.kt @@ -0,0 +1,23 @@ +package com.ivy.core.domain.pure.ui + +fun groupByRows( + items: List, + itemsPerRow: Int, +): List> { + val rows = mutableListOf>() + var row = mutableListOf() + for (icon in items) { + row.add(icon) + if (row.size == itemsPerRow) { + // row finished => add it and start a new row + rows.add(row) + // row.clear() won't work because it clears the already added row + row = mutableListOf() + } + } + if (row.isNotEmpty()) { + // add the last not finished row + rows.add(row) + } + return rows +} diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/util/FlowUtil.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/util/FlowUtil.kt new file mode 100644 index 0000000..ed45583 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/util/FlowUtil.kt @@ -0,0 +1,380 @@ +package com.ivy.core.domain.pure.util + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* + +/** + * @return list of flows -> flow of list + */ +inline fun combineList(flows: List>): Flow> = + if (flows.isEmpty()) flowOf(emptyList()) else combine(flows, Array::toList) + +inline fun combineSafe( + flows: List>, + ifEmpty: R, + crossinline transform: suspend (List) -> R, +): Flow = if (flows.isEmpty()) flowOf(ifEmpty) else + combine(flows) { res -> transform(res.toList()) } + +@OptIn(ExperimentalCoroutinesApi::class) +inline fun Flow>.flattenLatest(): Flow = + flatMapLatest { it } + + +// region combine (more than 5) +@Suppress("UNCHECKED_CAST") +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6) -> R +): Flow = combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + ) +} + +@Suppress("UNCHECKED_CAST") +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R +): Flow = combine(flow, flow2, flow3, flow4, flow5, flow6, flow7) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + ) +} + +@Suppress("UNCHECKED_CAST") +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + flow8: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8) -> R +): Flow = combine(flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + args[7] as T8, + ) +} + +@Suppress("UNCHECKED_CAST") +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + flow8: Flow, + flow9: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9) -> R +): Flow = combine( + flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8, flow9 +) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + args[7] as T8, + args[8] as T9, + ) +} + +@Suppress("UNCHECKED_CAST") +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + flow8: Flow, + flow9: Flow, + flow10: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10) -> R +): Flow = combine( + flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8, flow9, flow10 +) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + args[7] as T8, + args[8] as T9, + args[9] as T10, + ) +} + +@Suppress("UNCHECKED_CAST") +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + flow8: Flow, + flow9: Flow, + flow10: Flow, + flow11: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11) -> R +): Flow = combine( + flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8, flow9, flow10, flow11 +) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + args[7] as T8, + args[8] as T9, + args[9] as T10, + args[10] as T11, + ) +} + +@Suppress("UNCHECKED_CAST") +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + flow8: Flow, + flow9: Flow, + flow10: Flow, + flow11: Flow, + flow12: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12) -> R +): Flow = combine( + flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8, flow9, flow10, flow11, flow12 +) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + args[7] as T8, + args[8] as T9, + args[9] as T10, + args[10] as T11, + args[11] as T12, + ) +} + +@Suppress("UNCHECKED_CAST") +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + flow8: Flow, + flow9: Flow, + flow10: Flow, + flow11: Flow, + flow12: Flow, + flow13: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13) -> R +): Flow = combine( + flow, flow2, flow3, flow4, flow5, flow6, + flow7, flow8, flow9, flow10, flow11, flow12, flow13 +) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + args[7] as T8, + args[8] as T9, + args[9] as T10, + args[10] as T11, + args[11] as T12, + args[12] as T13, + ) +} + +@Suppress("UNCHECKED_CAST") +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + flow8: Flow, + flow9: Flow, + flow10: Flow, + flow11: Flow, + flow12: Flow, + flow13: Flow, + flow14: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14) -> R +): Flow = combine( + flow, flow2, flow3, flow4, flow5, flow6, + flow7, flow8, flow9, flow10, flow11, flow12, flow13, flow14 +) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + args[7] as T8, + args[8] as T9, + args[9] as T10, + args[10] as T11, + args[11] as T12, + args[12] as T13, + args[13] as T14, + ) +} + +@Suppress("UNCHECKED_CAST") +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + flow8: Flow, + flow9: Flow, + flow10: Flow, + flow11: Flow, + flow12: Flow, + flow13: Flow, + flow14: Flow, + flow15: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15) -> R +): Flow = combine( + flow, flow2, flow3, flow4, flow5, flow6, + flow7, flow8, flow9, flow10, flow11, flow12, + flow13, flow14, flow15 +) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + args[7] as T8, + args[8] as T9, + args[9] as T10, + args[10] as T11, + args[11] as T12, + args[12] as T13, + args[13] as T14, + args[14] as T15, + ) +} + +@Suppress("UNCHECKED_CAST") +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + flow8: Flow, + flow9: Flow, + flow10: Flow, + flow11: Flow, + flow12: Flow, + flow13: Flow, + flow14: Flow, + flow15: Flow, + flow16: Flow, + transform: suspend ( + T1, T2, T3, T4, T5, + T6, T7, T8, T9, T10, + T11, T12, T13, T14, T15, T16 + ) -> R +): Flow = combine( + flow, flow2, flow3, flow4, flow5, flow6, + flow7, flow8, flow9, flow10, flow11, flow12, + flow13, flow14, flow15, flow16 +) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + args[7] as T8, + args[8] as T9, + args[9] as T10, + args[10] as T11, + args[11] as T12, + args[12] as T13, + args[13] as T14, + args[14] as T15, + args[15] as T16, + ) +} +// endregion \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/util/NonEmptyListUtil.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/util/NonEmptyListUtil.kt new file mode 100644 index 0000000..f90ca7f --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/util/NonEmptyListUtil.kt @@ -0,0 +1,17 @@ +package com.ivy.core.domain.pure.util + +import arrow.core.NonEmptyList + + +suspend fun NonEmptyList.mapIndexedNel( + f: suspend (Int, T) -> T +): NonEmptyList { + return NonEmptyList.fromListUnsafe( + this.mapIndexed { index, value -> + f(index, value) + } + ) +} + +fun nonEmptyListOfZeros(n: Int): NonEmptyList = + NonEmptyList.fromListUnsafe(List(n) { 0.0 }) \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/util/NumberUtil.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/util/NumberUtil.kt new file mode 100644 index 0000000..aaacd04 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/util/NumberUtil.kt @@ -0,0 +1,67 @@ +package com.ivy.core.domain.pure.util + +import java.math.BigDecimal +import java.text.DecimalFormat +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * @return whether a number is "fiat" significant => at least 1 penny. + * True if **the absolute value of the number is at least 0.01**. + */ +fun isSignificant(number: Double) = abs(number) > 0.009 + +/** + * Not [isSignificant]. + */ +fun isInsignificant(number: Double) = !isSignificant(number) + +// region Split double into int part and decimal part +data class SplitDouble( + val intPart: Int, + val decimalPart: Double +) + +fun split(number: Double): SplitDouble { + val numberStr = number.toString() + val numberBigDecimal = BigDecimal(numberStr) + val intPart: Int = numberBigDecimal.toInt() + val decimalPart = numberBigDecimal.subtract(BigDecimal(intPart)).toDouble() + return SplitDouble( + intPart = intPart, + decimalPart = decimalPart + ) +} +// endregion + +// region Shorten big numbers, 10,500.50 => 10,5k +/** + * Formats a number in a short fashion using **k (kilo)** and **m (million)** symbols. + * + * **Examples:** + * - 1,530 => 1,53k + * - 1,000,000.52 => 1m + * - 900 => 900.00 + */ +fun formatShortened(number: Double): String { + fun formatShortened(shortened: Double, magnitude: String): String { + val decimalPart = split(shortened).decimalPart + return if (isSignificant(decimalPart)) { + val df = DecimalFormat("###,##0.##") + "${df.format(shortened)}$magnitude" + } else { + "${shortened.roundToInt()}$magnitude" + } + } + + return when { + abs(number) >= 1_000_000 -> { + formatShortened(number / 1_000_000, "m") + } + abs(number) >= 1_000 -> { + formatShortened(number / 1_000, "k") + } + else -> DecimalFormat("0.##").format(number) + } +} +// endregion \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/pure/util/TextUtil.kt b/core/domain/src/main/java/com/ivy/core/domain/pure/util/TextUtil.kt new file mode 100644 index 0000000..e46c3d9 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/pure/util/TextUtil.kt @@ -0,0 +1,6 @@ +package com.ivy.core.domain.pure.util + +fun beautify(text: String?): String? = + text?.trim()?.takeIf { it.isNotBlank() } + +fun String?.takeIfNotBlank(): String? = this?.takeIf { it.isNotBlank() } \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/test/TestIdlingResource.kt b/core/domain/src/main/java/com/ivy/core/domain/test/TestIdlingResource.kt new file mode 100644 index 0000000..0de8c08 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/test/TestIdlingResource.kt @@ -0,0 +1,32 @@ +package com.ivy.core.domain.test + +import androidx.compose.ui.test.IdlingResource +import java.util.concurrent.atomic.AtomicInteger + +object TestIdlingResource { + private val counter = AtomicInteger(0) + + @JvmField + val idlingResource = object : IdlingResource { + override val isIdleNow: Boolean + get() = counter.get() == 0 + } + + fun increment() { + counter.incrementAndGet() + } + + fun decrement() { + if (!idlingResource.isIdleNow) { + counter.decrementAndGet() + } + } + + fun reset() { + counter.set(0) + } + + fun get(): Int { + return counter.get() + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/ivy/core/domain/test/TestingContext.kt b/core/domain/src/main/java/com/ivy/core/domain/test/TestingContext.kt new file mode 100644 index 0000000..cbf6f03 --- /dev/null +++ b/core/domain/src/main/java/com/ivy/core/domain/test/TestingContext.kt @@ -0,0 +1,5 @@ +package com.ivy.core.domain.test + +object TestingContext { + var inTest = false +} \ No newline at end of file diff --git a/core/exchange-provider/.gitignore b/core/exchange-provider/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core/exchange-provider/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/exchange-provider/README.md b/core/exchange-provider/README.md new file mode 100644 index 0000000..f365f05 --- /dev/null +++ b/core/exchange-provider/README.md @@ -0,0 +1,3 @@ +# [Core] Exchange rates + +Fetches **exchange rates** for currencies codes from an **remote** exchange rates proider. \ No newline at end of file diff --git a/core/exchange-provider/build.gradle.kts b/core/exchange-provider/build.gradle.kts new file mode 100644 index 0000000..1bf21eb --- /dev/null +++ b/core/exchange-provider/build.gradle.kts @@ -0,0 +1,22 @@ +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Networking +import com.ivy.buildsrc.RoomDB +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` + id("dagger.hilt.android.plugin") +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":core:data-model")) + implementation(project(":network")) + RoomDB(api = true) + Networking(api = false) + + Testing() +} \ No newline at end of file diff --git a/core/exchange-provider/src/main/AndroidManifest.xml b/core/exchange-provider/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6bf9c78 --- /dev/null +++ b/core/exchange-provider/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/core/exchange-provider/src/main/java/com/ivy/exchange/RemoteExchangeProvider.kt b/core/exchange-provider/src/main/java/com/ivy/exchange/RemoteExchangeProvider.kt new file mode 100644 index 0000000..8e69d9a --- /dev/null +++ b/core/exchange-provider/src/main/java/com/ivy/exchange/RemoteExchangeProvider.kt @@ -0,0 +1,17 @@ +package com.ivy.exchange + +import com.ivy.data.CurrencyCode +import com.ivy.data.ExchangeRatesMap +import com.ivy.data.exchange.ExchangeProvider + +interface RemoteExchangeProvider { + suspend fun fetchExchangeRates(baseCurrency: CurrencyCode): Result + + /** + * @param ratesMap a map of rates **{base currency}**-{currency} to rate pairs + */ + data class Result( + val ratesMap: ExchangeRatesMap, + val provider: ExchangeProvider + ) +} \ No newline at end of file diff --git a/core/exchange-provider/src/main/java/com/ivy/exchange/di/ExchangeModuleDI.kt b/core/exchange-provider/src/main/java/com/ivy/exchange/di/ExchangeModuleDI.kt new file mode 100644 index 0000000..06864ae --- /dev/null +++ b/core/exchange-provider/src/main/java/com/ivy/exchange/di/ExchangeModuleDI.kt @@ -0,0 +1,15 @@ +package com.ivy.exchange.di + +import com.ivy.exchange.RemoteExchangeProvider +import com.ivy.exchange.fawazahmed0.Fawazahmed0ExchangeProvider +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class ExchangeModuleDI { + @Binds + abstract fun exchangeProvider(provider: Fawazahmed0ExchangeProvider): RemoteExchangeProvider +} \ No newline at end of file diff --git a/core/exchange-provider/src/main/java/com/ivy/exchange/fawazahmed0/Fawazahmed0ExchangeProvider.kt b/core/exchange-provider/src/main/java/com/ivy/exchange/fawazahmed0/Fawazahmed0ExchangeProvider.kt new file mode 100644 index 0000000..c23cf83 --- /dev/null +++ b/core/exchange-provider/src/main/java/com/ivy/exchange/fawazahmed0/Fawazahmed0ExchangeProvider.kt @@ -0,0 +1,77 @@ +package com.ivy.exchange.fawazahmed0 + +import com.ivy.data.CurrencyCode +import com.ivy.data.exchange.ExchangeProvider +import com.ivy.exchange.RemoteExchangeProvider +import com.ivy.network.ktorClient +import io.ktor.client.call.* +import io.ktor.client.request.* +import javax.inject.Inject + +class Fawazahmed0ExchangeProvider @Inject constructor( + +) : RemoteExchangeProvider { + companion object { + private val FALLBACK_URLS = listOf( + "https://cdn.jsdelivr.net/gh/fawazahmed0/currency-api@1/latest/currencies/eur.json", + "https://cdn.jsdelivr.net/gh/fawazahmed0/currency-api@1/latest/currencies/eur.min.json", + "https://raw.githubusercontent.com/fawazahmed0/currency-api/1/latest/currencies/eur.min.json", + "https://raw.githubusercontent.com/fawazahmed0/currency-api/1/latest/currencies/eur.json", + ) + } + + override suspend fun fetchExchangeRates(baseCurrency: CurrencyCode): RemoteExchangeProvider.Result { + if (baseCurrency.isBlank()) return failure() + + var eurRates: Map = emptyMap() + for (url in FALLBACK_URLS) { + eurRates = fetchEurBaseRates(url) + if (eurRates.isNotEmpty()) break // rates fetched successfully, stop! + } + if (eurRates.isEmpty()) return failure() // empty rates = no rates = failure + + // At this point we must have non-empty EUR rates map + // Now we must convert them to base currency + /* + "eur": { + "bgn": 1.955902, + "usd": 1.062366, + } + */ + // the API works with lowercase currency codes + val baseCurrencyLower = baseCurrency.lowercase() + val eurBaseCurrRateNonZero = eurRates[baseCurrencyLower] + ?.takeIf { it > 0.0 } ?: return failure() + + val rates = eurRates.mapNotNull { (target, eurTargetRate) -> + try { + if (eurTargetRate > 0.0) { + val baseCurrencyTargetRate = eurTargetRate / eurBaseCurrRateNonZero + target.uppercase() to baseCurrencyTargetRate + } else null + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + return RemoteExchangeProvider.Result( + ratesMap = rates.toMap(), + provider = ExchangeProvider.Fawazahmed0 + ) + } + + private suspend fun fetchEurBaseRates(url: String): Map { + return try { + ktorClient().get(url).body().eur + } catch (e: Exception) { + e.printStackTrace() + emptyMap() + } + } + + private fun failure() = RemoteExchangeProvider.Result( + ratesMap = emptyMap(), + provider = ExchangeProvider.Fawazahmed0 + ) +} \ No newline at end of file diff --git a/core/exchange-provider/src/main/java/com/ivy/exchange/fawazahmed0/Fawazahmed0Response.kt b/core/exchange-provider/src/main/java/com/ivy/exchange/fawazahmed0/Fawazahmed0Response.kt new file mode 100644 index 0000000..2036b4a --- /dev/null +++ b/core/exchange-provider/src/main/java/com/ivy/exchange/fawazahmed0/Fawazahmed0Response.kt @@ -0,0 +1,10 @@ +package com.ivy.exchange.fawazahmed0 + +import com.google.gson.annotations.SerializedName + +data class Fawazahmed0Response( + @SerializedName("date") + val date: String, + @SerializedName("eur") + val eur: Map, +) \ No newline at end of file diff --git a/core/persistence/.gitignore b/core/persistence/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core/persistence/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/persistence/README.md b/core/persistence/README.md new file mode 100644 index 0000000..453c435 --- /dev/null +++ b/core/persistence/README.md @@ -0,0 +1,5 @@ +# [Core] Persistence + +Responsible for the persistence of the `:core:data-model` in Room DB and implements the `IvyWalletDatastore` for key-value pairs. + +Holds all entities that map 1:1 with core's data model. \ No newline at end of file diff --git a/core/persistence/build.gradle.kts b/core/persistence/build.gradle.kts new file mode 100644 index 0000000..5065088 --- /dev/null +++ b/core/persistence/build.gradle.kts @@ -0,0 +1,31 @@ +import com.ivy.buildsrc.DataStore +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.RoomDB +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` + `kotlin-android` + `kotlin-kapt` // for Room DB +} + +android { + defaultConfig { + kapt { + arguments { + arg("room.schemaLocation", "$projectDir/../room-db-schemas") + } + } + } +} + +dependencies { + Hilt() + implementation(project(":common:main")) + RoomDB(api = false) + DataStore(api = true) + + Testing() +} \ No newline at end of file diff --git a/core/persistence/src/main/AndroidManifest.xml b/core/persistence/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bd1db18 --- /dev/null +++ b/core/persistence/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/GeneralTypeConverters.kt b/core/persistence/src/main/java/com/ivy/core/persistence/GeneralTypeConverters.kt new file mode 100644 index 0000000..8b990e3 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/GeneralTypeConverters.kt @@ -0,0 +1,24 @@ +package com.ivy.core.persistence + +import androidx.room.TypeConverter +import com.ivy.data.SyncState +import java.time.Instant + + +class GeneralTypeConverters { + // region Instant + @TypeConverter + fun ser(instant: Instant): Long = instant.epochSecond + + @TypeConverter + fun instant(epochSecond: Long): Instant = Instant.ofEpochSecond(epochSecond) + // endregion + + // region SyncState + @TypeConverter + fun ser(syncState: SyncState): Int = syncState.code + + @TypeConverter + fun syncState(code: Int): SyncState = SyncState.fromCode(code) ?: SyncState.Syncing + // endregion +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/IvyWalletCoreDb.kt b/core/persistence/src/main/java/com/ivy/core/persistence/IvyWalletCoreDb.kt new file mode 100644 index 0000000..811c651 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/IvyWalletCoreDb.kt @@ -0,0 +1,96 @@ +package com.ivy.core.persistence + +import android.content.Context +import androidx.room.* +import com.ivy.core.persistence.algorithm.accountcache.AccountCacheDao +import com.ivy.core.persistence.algorithm.accountcache.AccountCacheEntity +import com.ivy.core.persistence.algorithm.calc.CalcTrnDao +import com.ivy.core.persistence.algorithm.calc.RatesDao +import com.ivy.core.persistence.algorithm.trnhistory.CalcHistoryTrnDao +import com.ivy.core.persistence.algorithm.trnhistory.CalcHistoryTrnView +import com.ivy.core.persistence.dao.AttachmentDao +import com.ivy.core.persistence.dao.account.AccountDao +import com.ivy.core.persistence.dao.account.AccountFolderDao +import com.ivy.core.persistence.dao.category.CategoryDao +import com.ivy.core.persistence.dao.exchange.ExchangeRateDao +import com.ivy.core.persistence.dao.exchange.ExchangeRateOverrideDao +import com.ivy.core.persistence.dao.tag.TagDao +import com.ivy.core.persistence.dao.trn.TransactionDao +import com.ivy.core.persistence.dao.trn.TrnLinkRecordDao +import com.ivy.core.persistence.dao.trn.TrnMetadataDao +import com.ivy.core.persistence.dao.trn.TrnTagDao +import com.ivy.core.persistence.entity.account.AccountEntity +import com.ivy.core.persistence.entity.account.AccountFolderEntity +import com.ivy.core.persistence.entity.account.converter.AccountTypeConverter +import com.ivy.core.persistence.entity.attachment.AttachmentEntity +import com.ivy.core.persistence.entity.attachment.converter.AttachmentTypeConverters +import com.ivy.core.persistence.entity.category.CategoryEntity +import com.ivy.core.persistence.entity.category.converter.CategoryTypeConverter +import com.ivy.core.persistence.entity.exchange.ExchangeRateEntity +import com.ivy.core.persistence.entity.exchange.ExchangeRateOverrideEntity +import com.ivy.core.persistence.entity.exchange.converter.ExchangeRateTypeConverter +import com.ivy.core.persistence.entity.tag.TagEntity +import com.ivy.core.persistence.entity.trn.TransactionEntity +import com.ivy.core.persistence.entity.trn.TrnLinkRecordEntity +import com.ivy.core.persistence.entity.trn.TrnMetadataEntity +import com.ivy.core.persistence.entity.trn.TrnTagEntity +import com.ivy.core.persistence.entity.trn.converter.TrnTypeConverters +import com.ivy.core.persistence.migration.Migration1to2_LastUpdated + +@Database( + entities = [ + TransactionEntity::class, TrnLinkRecordEntity::class, + TrnMetadataEntity::class, AttachmentEntity::class, + AccountEntity::class, AccountFolderEntity::class, + CategoryEntity::class, ExchangeRateEntity::class, + ExchangeRateOverrideEntity::class, TagEntity::class, + TrnTagEntity::class, AccountCacheEntity::class, + ], + views = [ + CalcHistoryTrnView::class + ], + version = 6, + autoMigrations = [ + AutoMigration(from = 2, to = 3), + AutoMigration(from = 3, to = 4), + AutoMigration(from = 4, to = 5), + AutoMigration(from = 5, to = 6), + ], + exportSchema = true, +) +@TypeConverters( + GeneralTypeConverters::class, + TrnTypeConverters::class, AttachmentTypeConverters::class, + AccountTypeConverter::class, CategoryTypeConverter::class, + ExchangeRateTypeConverter::class, +) +abstract class IvyWalletCoreDb : RoomDatabase() { + abstract fun trnDao(): TransactionDao + abstract fun trnLinkRecordDao(): TrnLinkRecordDao + abstract fun trnMetadataDao(): TrnMetadataDao + abstract fun trnTagDao(): TrnTagDao + abstract fun attachmentDao(): AttachmentDao + abstract fun accountDao(): AccountDao + abstract fun accountFolderDao(): AccountFolderDao + abstract fun categoryDao(): CategoryDao + abstract fun tagDao(): TagDao + abstract fun exchangeRateDao(): ExchangeRateDao + abstract fun exchangeRateOverrideDao(): ExchangeRateOverrideDao + + abstract fun calcTrnDao(): CalcTrnDao + abstract fun calcHistoryTrnDao(): CalcHistoryTrnDao + abstract fun ratesDao(): RatesDao + abstract fun accountCacheDao(): AccountCacheDao + + companion object { + private const val DB_NAME = "ivy-wallet-core.db" + + fun create(applicationContext: Context): IvyWalletCoreDb { + return Room.databaseBuilder( + applicationContext, IvyWalletCoreDb::class.java, DB_NAME + ).addMigrations( + Migration1to2_LastUpdated() + ).build() + } + } +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/accountcache/AccountCacheDao.kt b/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/accountcache/AccountCacheDao.kt new file mode 100644 index 0000000..1d16bc5 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/accountcache/AccountCacheDao.kt @@ -0,0 +1,25 @@ +package com.ivy.core.persistence.algorithm.accountcache + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow +import java.time.Instant + +@Dao +interface AccountCacheDao { + @Query("SELECT * FROM account_cache WHERE accountId = :accountId LIMIT 1") + fun findAccountCache(accountId: String): Flow + + @Query("SELECT timestamp FROM account_cache WHERE accountId = :accountId LIMIT 1") + suspend fun findTimestampById(accountId: String): Instant? + + @Upsert + suspend fun save(cache: AccountCacheEntity) + + @Query("DELETE FROM account_cache WHERE accountId = :accountId") + suspend fun delete(accountId: String) + + @Query("DELETE FROM account_cache") + suspend fun deleteAll() +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/accountcache/AccountCacheEntity.kt b/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/accountcache/AccountCacheEntity.kt new file mode 100644 index 0000000..b4943b1 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/accountcache/AccountCacheEntity.kt @@ -0,0 +1,23 @@ +package com.ivy.core.persistence.algorithm.accountcache + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.time.Instant + +@Entity(tableName = "account_cache") +data class AccountCacheEntity( + @PrimaryKey + @ColumnInfo(name = "accountId", index = true) + val accountId: String, + @ColumnInfo(name = "incomesJson") + val incomesJson: String, + @ColumnInfo(name = "expensesJson") + val expensesJson: String, + @ColumnInfo(name = "incomesCount") + val incomesCount: Int, + @ColumnInfo(name = "expensesCount") + val expensesCount: Int, + @ColumnInfo(name = "timestamp") + val timestamp: Instant, +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/calc/CalcTrn.kt b/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/calc/CalcTrn.kt new file mode 100644 index 0000000..1f9f9b2 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/calc/CalcTrn.kt @@ -0,0 +1,17 @@ +package com.ivy.core.persistence.algorithm.calc + +import androidx.room.ColumnInfo +import com.ivy.data.CurrencyCode +import com.ivy.data.transaction.TransactionType +import java.time.Instant + +data class CalcTrn( + @ColumnInfo(name = "amount") + val amount: Double, + @ColumnInfo(name = "currency") + val currency: CurrencyCode, + @ColumnInfo(name = "type") + val type: TransactionType, + @ColumnInfo(name = "time") + val time: Instant, +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/calc/CalcTrnDao.kt b/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/calc/CalcTrnDao.kt new file mode 100644 index 0000000..7e01651 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/calc/CalcTrnDao.kt @@ -0,0 +1,28 @@ +package com.ivy.core.persistence.algorithm.calc + +import androidx.room.Dao +import androidx.room.Query +import com.ivy.core.persistence.entity.trn.data.ActualCode +import com.ivy.data.DELETING +import kotlinx.coroutines.flow.Flow +import java.time.Instant + +@Dao +interface CalcTrnDao { + @Query( + "SELECT amount, currency, type, time FROM transactions WHERE" + + " accountId = :accountId AND time > :timestamp AND timeType = $ActualCode" + + " AND sync != $DELETING" + ) + fun findActualByAccountAfter( + accountId: String, + timestamp: Instant + ): Flow> + + @Query( + "SELECT amount, currency, type, time FROM transactions WHERE" + + " accountId = :accountId AND timeType = $ActualCode" + + " AND sync != $DELETING" + ) + fun findAllActualByAccount(accountId: String): Flow> +} diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/calc/Rate.kt b/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/calc/Rate.kt new file mode 100644 index 0000000..ae25ff3 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/calc/Rate.kt @@ -0,0 +1,11 @@ +package com.ivy.core.persistence.algorithm.calc + +import androidx.room.ColumnInfo +import com.ivy.data.CurrencyCode + +data class Rate( + @ColumnInfo(name = "rate") + val rate: Double, + @ColumnInfo(name = "currency") + val currency: CurrencyCode +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/calc/RatesDao.kt b/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/calc/RatesDao.kt new file mode 100644 index 0000000..ad51423 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/calc/RatesDao.kt @@ -0,0 +1,19 @@ +package com.ivy.core.persistence.algorithm.calc + +import androidx.room.Dao +import androidx.room.Query +import com.ivy.data.CurrencyCode +import com.ivy.data.DELETING +import kotlinx.coroutines.flow.Flow + +@Dao +interface RatesDao { + @Query("SELECT rate, currency FROM exchange_rates WHERE baseCurrency = :baseCurrency") + fun findAll(baseCurrency: CurrencyCode): Flow> + + @Query( + "SELECT rate, currency FROM exchange_rates_override WHERE baseCurrency = :baseCurrency" + + " AND sync != $DELETING" + ) + fun findAllOverrides(baseCurrency: CurrencyCode): Flow> +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/trnhistory/CalcHistoryTrnDao.kt b/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/trnhistory/CalcHistoryTrnDao.kt new file mode 100644 index 0000000..c7f8680 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/trnhistory/CalcHistoryTrnDao.kt @@ -0,0 +1,20 @@ +package com.ivy.core.persistence.algorithm.trnhistory + +import androidx.room.Dao +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import java.time.Instant + +@Dao +interface CalcHistoryTrnDao { + /** + * - hidden transactions must not appear in the history + */ + @Query( + "SELECT * FROM CalcHistoryTrnView WHERE time >= :from AND time <= :to" + ) + fun findAllInPeriod( + from: Instant, + to: Instant + ): Flow> +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/trnhistory/CalcHistoryTrnView.kt b/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/trnhistory/CalcHistoryTrnView.kt new file mode 100644 index 0000000..9daae62 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/algorithm/trnhistory/CalcHistoryTrnView.kt @@ -0,0 +1,52 @@ +package com.ivy.core.persistence.algorithm.trnhistory + +import androidx.room.ColumnInfo +import androidx.room.DatabaseView +import com.ivy.core.persistence.entity.trn.data.TrnTimeType +import com.ivy.data.CurrencyCode +import com.ivy.data.DELETING +import com.ivy.data.transaction.TransactionType +import com.ivy.data.transaction.TrnPurpose +import com.ivy.data.transaction.TrnStateHidden +import java.time.Instant + +@DatabaseView( + "SELECT amount, currency, type, time," + + "transactions.id, accountId, categoryId, title, description, timeType," + + "purpose, trn_links.batchId" + + " FROM transactions" + + " LEFT JOIN trn_links ON trn_links.trnId = transactions.id" + + " WHERE transactions.state != $TrnStateHidden AND transactions.sync != $DELETING", + viewName = "CalcHistoryTrnView" +) +data class CalcHistoryTrnView( + // region CalcTrn + @ColumnInfo(name = "amount") + val amount: Double, + @ColumnInfo(name = "currency") + val currency: CurrencyCode, + @ColumnInfo(name = "type") + val type: TransactionType, + @ColumnInfo(name = "time") + val time: Instant, + // endregion + + @ColumnInfo(name = "id") + val id: String, + @ColumnInfo(name = "accountId") + val accountId: String, + @ColumnInfo(name = "categoryId") + val categoryId: String?, + @ColumnInfo(name = "title") + val title: String?, + @ColumnInfo(name = "description") + val description: String?, + @ColumnInfo(name = "timeType") + val timeType: TrnTimeType, + @ColumnInfo(name = "purpose") + val purpose: TrnPurpose?, + + // from "trn_links" + @ColumnInfo(name = "batchId") + val batchId: String? +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/Read.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/Read.kt new file mode 100644 index 0000000..0048ca0 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/Read.kt @@ -0,0 +1,10 @@ +package com.ivy.core.persistence.api + +import com.ivy.core.data.sync.UniqueId +import kotlinx.coroutines.flow.Flow + +interface Read { + fun single(id: TID): Flow + + fun many(query: Q): Flow> +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/ReadSyncable.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/ReadSyncable.kt new file mode 100644 index 0000000..518fd09 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/ReadSyncable.kt @@ -0,0 +1,10 @@ +package com.ivy.core.persistence.api + +import com.ivy.core.data.sync.Syncable +import com.ivy.core.data.sync.UniqueId + +interface ReadSyncable : Read { + suspend fun allPartial(): List + + suspend fun byIds(ids: List): List +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/Write.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/Write.kt new file mode 100644 index 0000000..ab5f7bc --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/Write.kt @@ -0,0 +1,18 @@ +package com.ivy.core.persistence.api + +import arrow.core.Either +import arrow.core.NonEmptyList +import com.ivy.core.persistence.api.data.PersistenceError + +interface Write { + suspend fun save( + item: T + ): Either + + suspend fun saveMany( + items: NonEmptyList, + ): Either + + suspend fun delete(id: TID): Either + suspend fun deleteMany(ids: NonEmptyList): Either +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/account/AccountCacheRead.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/account/AccountCacheRead.kt new file mode 100644 index 0000000..55ef560 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/account/AccountCacheRead.kt @@ -0,0 +1,9 @@ +package com.ivy.core.persistence.api.account + +import com.ivy.core.data.AccountId +import com.ivy.core.data.calculation.AccountCache +import com.ivy.core.persistence.api.Read + +interface AccountCacheRead : Read { + suspend fun all(): List +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/account/AccountCacheWrite.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/account/AccountCacheWrite.kt new file mode 100644 index 0000000..2fbcef7 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/account/AccountCacheWrite.kt @@ -0,0 +1,7 @@ +package com.ivy.core.persistence.api.account + +import com.ivy.core.data.AccountId +import com.ivy.core.data.calculation.AccountCache +import com.ivy.core.persistence.api.Write + +interface AccountCacheWrite : Write \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/account/AccountRead.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/account/AccountRead.kt new file mode 100644 index 0000000..168dec2 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/account/AccountRead.kt @@ -0,0 +1,13 @@ +package com.ivy.core.persistence.api.account + +import com.ivy.core.data.Account +import com.ivy.core.data.AccountId +import com.ivy.core.persistence.api.ReadSyncable + +interface AccountRead : ReadSyncable { + +} + +sealed interface AccountQuery { + object All : AccountQuery +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/account/AccountWrite.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/account/AccountWrite.kt new file mode 100644 index 0000000..5497bcb --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/account/AccountWrite.kt @@ -0,0 +1,7 @@ +package com.ivy.core.persistence.api.account + +import com.ivy.core.data.Account +import com.ivy.core.data.AccountId +import com.ivy.core.persistence.api.Write + +interface AccountWrite : Write \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/attachment/AttachmentRead.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/attachment/AttachmentRead.kt new file mode 100644 index 0000000..c0a9022 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/attachment/AttachmentRead.kt @@ -0,0 +1,10 @@ +package com.ivy.core.persistence.api.attachment + +import com.ivy.core.data.Attachment +import com.ivy.core.data.AttachmentId +import com.ivy.core.persistence.api.ReadSyncable + +interface AttachmentRead : ReadSyncable { +} + +sealed interface AttachmentQuery diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/attachment/AttachmentWrite.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/attachment/AttachmentWrite.kt new file mode 100644 index 0000000..95b884b --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/attachment/AttachmentWrite.kt @@ -0,0 +1,7 @@ +package com.ivy.core.persistence.api.attachment + +import com.ivy.core.data.Attachment +import com.ivy.core.data.AttachmentId +import com.ivy.core.persistence.api.Write + +interface AttachmentWrite : Write \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/budget/BudgetRead.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/budget/BudgetRead.kt new file mode 100644 index 0000000..d0047dc --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/budget/BudgetRead.kt @@ -0,0 +1,10 @@ +package com.ivy.core.persistence.api.budget + +import com.ivy.core.data.Budget +import com.ivy.core.data.BudgetId +import com.ivy.core.persistence.api.ReadSyncable + +interface BudgetRead : ReadSyncable { +} + +sealed interface BudgetQuery diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/budget/BudgetWrite.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/budget/BudgetWrite.kt new file mode 100644 index 0000000..db7c1f0 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/budget/BudgetWrite.kt @@ -0,0 +1,7 @@ +package com.ivy.core.persistence.api.budget + +import com.ivy.core.data.Budget +import com.ivy.core.data.BudgetId +import com.ivy.core.persistence.api.Write + +interface BudgetWrite : Write \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/category/CategoryRead.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/category/CategoryRead.kt new file mode 100644 index 0000000..8dba445 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/category/CategoryRead.kt @@ -0,0 +1,10 @@ +package com.ivy.core.persistence.api.category + +import com.ivy.core.data.Category +import com.ivy.core.data.CategoryId +import com.ivy.core.persistence.api.ReadSyncable + +interface CategoryRead : ReadSyncable { +} + +sealed interface CategoryQuery diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/category/CategoryWrite.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/category/CategoryWrite.kt new file mode 100644 index 0000000..8f5a880 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/category/CategoryWrite.kt @@ -0,0 +1,7 @@ +package com.ivy.core.persistence.api.category + +import com.ivy.core.data.Category +import com.ivy.core.data.CategoryId +import com.ivy.core.persistence.api.Write + +interface CategoryWrite : Write \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/data/PersistenceError.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/data/PersistenceError.kt new file mode 100644 index 0000000..fe22201 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/data/PersistenceError.kt @@ -0,0 +1,5 @@ +package com.ivy.core.persistence.api.data + +sealed interface PersistenceError { + val reason: Throwable +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/recurring/RecurringRuleRead.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/recurring/RecurringRuleRead.kt new file mode 100644 index 0000000..e4dc515 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/recurring/RecurringRuleRead.kt @@ -0,0 +1,14 @@ +package com.ivy.core.persistence.api.recurring + +import com.ivy.core.data.RecurringRule +import com.ivy.core.data.RecurringRuleId +import com.ivy.core.data.common.TimeRange +import com.ivy.core.persistence.api.ReadSyncable + +interface RecurringRuleRead : ReadSyncable { + +} + +sealed interface RecurringRuleQuery { + data class ForPeriod(val range: TimeRange) : RecurringRuleQuery +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/recurring/RecurringRuleWrite.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/recurring/RecurringRuleWrite.kt new file mode 100644 index 0000000..3487b0a --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/recurring/RecurringRuleWrite.kt @@ -0,0 +1,7 @@ +package com.ivy.core.persistence.api.recurring + +import com.ivy.core.data.RecurringRule +import com.ivy.core.data.RecurringRuleId +import com.ivy.core.persistence.api.Write + +interface RecurringRuleWrite : Write \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/saving/SavingGoalRead.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/saving/SavingGoalRead.kt new file mode 100644 index 0000000..2997666 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/saving/SavingGoalRead.kt @@ -0,0 +1,10 @@ +package com.ivy.core.persistence.api.saving + +import com.ivy.core.data.SavingGoal +import com.ivy.core.data.SavingGoalId +import com.ivy.core.persistence.api.ReadSyncable + +interface SavingGoalRead : ReadSyncable { +} + +sealed interface SavingGoalQuery diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/saving/SavingGoalRecordRead.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/saving/SavingGoalRecordRead.kt new file mode 100644 index 0000000..cf7d94f --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/saving/SavingGoalRecordRead.kt @@ -0,0 +1,11 @@ +package com.ivy.core.persistence.api.saving + +import com.ivy.core.data.SavingGoalRecord +import com.ivy.core.data.SavingGoalRecordId +import com.ivy.core.persistence.api.ReadSyncable + +interface SavingGoalRecordRead : + ReadSyncable { +} + +sealed interface SavingGoalRecordQuery diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/saving/SavingGoalRecordWrite.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/saving/SavingGoalRecordWrite.kt new file mode 100644 index 0000000..06ed6e2 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/saving/SavingGoalRecordWrite.kt @@ -0,0 +1,8 @@ +package com.ivy.core.persistence.api.saving + +import com.ivy.core.data.SavingGoalRecord +import com.ivy.core.data.SavingGoalRecordId +import com.ivy.core.persistence.api.Write + +interface SavingGoalRecordWrite : Write { +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/saving/SavingGoalWrite.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/saving/SavingGoalWrite.kt new file mode 100644 index 0000000..86e745a --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/saving/SavingGoalWrite.kt @@ -0,0 +1,7 @@ +package com.ivy.core.persistence.api.saving + +import com.ivy.core.data.SavingGoal +import com.ivy.core.data.SavingGoalId +import com.ivy.core.persistence.api.Write + +interface SavingGoalWrite : Write \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/tag/TagRead.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/tag/TagRead.kt new file mode 100644 index 0000000..33f9b33 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/tag/TagRead.kt @@ -0,0 +1,10 @@ +package com.ivy.core.persistence.api.tag + +import com.ivy.core.data.Tag +import com.ivy.core.data.TagId +import com.ivy.core.persistence.api.ReadSyncable + +interface TagRead : ReadSyncable { +} + +sealed interface TagQuery diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/tag/TagWrite.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/tag/TagWrite.kt new file mode 100644 index 0000000..5a76aad --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/tag/TagWrite.kt @@ -0,0 +1,7 @@ +package com.ivy.core.persistence.api.tag + +import com.ivy.core.data.Tag +import com.ivy.core.data.TagId +import com.ivy.core.persistence.api.Write + +interface TagWrite : Write \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/transaction/LedgerRead.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/transaction/LedgerRead.kt new file mode 100644 index 0000000..d821175 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/transaction/LedgerRead.kt @@ -0,0 +1,21 @@ +package com.ivy.core.persistence.api.transaction + +import com.ivy.core.data.AccountId +import com.ivy.core.data.optimized.LedgerEntry +import com.ivy.core.persistence.api.Read +import java.time.LocalDateTime + +interface LedgerRead : Read { + +} + +sealed interface LedgerQuery { + data class ForAccount( + val accountId: AccountId + ) : LedgerQuery + + data class ForAccountAfter( + val accountId: AccountId, + val after: LocalDateTime + ) : LedgerQuery +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/transaction/TransactionRead.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/transaction/TransactionRead.kt new file mode 100644 index 0000000..f65fd37 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/transaction/TransactionRead.kt @@ -0,0 +1,22 @@ +package com.ivy.core.persistence.api.transaction + +import arrow.core.NonEmptyList +import com.ivy.core.data.Transaction +import com.ivy.core.data.TransactionId +import com.ivy.core.data.common.TimeRange +import com.ivy.core.persistence.api.ReadSyncable + +interface TransactionRead : ReadSyncable { + +} + +sealed interface TransactionQuery { + data class ByIds( + val ids: NonEmptyList + ) : TransactionQuery + + data class ForPeriod( + val range: TimeRange, + val actual: Boolean, + ) : TransactionQuery +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/api/transaction/TransactionWrite.kt b/core/persistence/src/main/java/com/ivy/core/persistence/api/transaction/TransactionWrite.kt new file mode 100644 index 0000000..d3d8f30 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/api/transaction/TransactionWrite.kt @@ -0,0 +1,7 @@ +package com.ivy.core.persistence.api.transaction + +import com.ivy.core.data.Transaction +import com.ivy.core.data.TransactionId +import com.ivy.core.persistence.api.Write + +interface TransactionWrite : Write \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dao/AttachmentDao.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dao/AttachmentDao.kt new file mode 100644 index 0000000..beb7cbb --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dao/AttachmentDao.kt @@ -0,0 +1,35 @@ +package com.ivy.core.persistence.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.ivy.core.persistence.entity.attachment.AttachmentEntity +import com.ivy.data.DELETING +import com.ivy.data.SyncState +import kotlinx.coroutines.flow.Flow + +@Dao +interface AttachmentDao { + // region Save + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(value: AttachmentEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(values: List) + // endregion + + // region Select + @Query("SELECT * FROM attachments WHERE sync != $DELETING") + suspend fun findAllBlocking(): List + + @Query("SELECT * FROM attachments WHERE associatedId = :associatedId AND sync != $DELETING") + fun findByAssociatedId(associatedId: String): Flow> + // endregion + + + // region Update + @Query("UPDATE attachments SET sync = :sync WHERE associatedId = :associatedId") + suspend fun updateSyncByAssociatedId(associatedId: String, sync: SyncState) + // endregion +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dao/account/AccountDao.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dao/account/AccountDao.kt new file mode 100644 index 0000000..8794f1d --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dao/account/AccountDao.kt @@ -0,0 +1,43 @@ +package com.ivy.core.persistence.dao.account + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.ivy.core.persistence.entity.account.AccountEntity +import com.ivy.data.DELETING +import com.ivy.data.SyncState +import kotlinx.coroutines.flow.Flow + +@Dao +interface AccountDao { + // region Save + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(values: List) + //endregion + + // region Select + @Query("SELECT * FROM accounts WHERE sync != $DELETING") + suspend fun findAllBlocking(): List + + @Query("SELECT * FROM accounts WHERE sync != $DELETING ORDER BY orderNum ASC") + suspend fun findAllOrdered(): List + + @Query("SELECT id FROM accounts WHERE sync != $DELETING") + suspend fun findAllIds(): List + + @Query("SELECT * FROM accounts WHERE sync != $DELETING ORDER BY orderNum ASC") + fun findAll(): Flow> + + @Query("SELECT * FROM accounts WHERE sync != $DELETING AND id = :accountId") + suspend fun findById(accountId: String): AccountEntity? + + @Query("SELECT MAX(orderNum) FROM accounts") + suspend fun findMaxOrderNum(): Double? + // endregion + + // region Update + @Query("UPDATE accounts SET sync = :sync WHERE id = :accountId") + suspend fun updateSync(accountId: String, sync: SyncState) + // endregion +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dao/account/AccountFolderDao.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dao/account/AccountFolderDao.kt new file mode 100644 index 0000000..da4ff58 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dao/account/AccountFolderDao.kt @@ -0,0 +1,37 @@ +package com.ivy.core.persistence.dao.account + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.ivy.core.persistence.entity.account.AccountFolderEntity +import com.ivy.data.DELETING +import com.ivy.data.SyncState +import kotlinx.coroutines.flow.Flow + +@Dao +interface AccountFolderDao { + // region Save + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(values: List) + //endregion + + // region Select + @Query("SELECT * FROM account_folders WHERE sync != $DELETING") + suspend fun findAllBlocking(): List + + @Query("SELECT * FROM account_folders WHERE sync != $DELETING ORDER BY orderNum ASC") + fun findAll(): Flow> + + @Query("SELECT * FROM account_folders WHERE sync != $DELETING AND id = :folderId") + suspend fun findById(folderId: String): AccountFolderEntity? + + @Query("SELECT MAX(orderNum) FROM account_folders") + suspend fun findMaxOrderNum(): Double? + // endregion + + // region Update + @Query("UPDATE account_folders SET sync = :sync WHERE id = :folderId") + suspend fun updateSync(folderId: String, sync: SyncState) + // endregion +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dao/category/CategoryDao.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dao/category/CategoryDao.kt new file mode 100644 index 0000000..9a71726 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dao/category/CategoryDao.kt @@ -0,0 +1,37 @@ +package com.ivy.core.persistence.dao.category + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.ivy.core.persistence.entity.category.CategoryEntity +import com.ivy.data.DELETING +import com.ivy.data.SyncState +import kotlinx.coroutines.flow.Flow + +@Dao +interface CategoryDao { + // region Save + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(values: Iterable) + // endregion + + // region Select + @Query("SELECT * FROM categories WHERE sync != $DELETING") + suspend fun findAllBlocking(): List + + @Query("SELECT * FROM categories WHERE sync != $DELETING AND id = :categoryId") + suspend fun findById(categoryId: String): CategoryEntity? + + @Query("SELECT * FROM categories WHERE sync != $DELETING ORDER BY orderNum ASC") + fun findAll(): Flow> + + @Query("SELECT MAX(orderNum) FROM categories WHERE parentCategoryId IS NULL") + suspend fun findMaxNoParentOrderNum(): Double? + // endregion + + // region Update + @Query("UPDATE categories SET sync = :sync WHERE id IN (:categoryIds)") + suspend fun updateSync(categoryIds: List, sync: SyncState) + // endregion +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dao/exchange/ExchangeRateDao.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dao/exchange/ExchangeRateDao.kt new file mode 100644 index 0000000..ae83634 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dao/exchange/ExchangeRateDao.kt @@ -0,0 +1,21 @@ +package com.ivy.core.persistence.dao.exchange + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.ivy.core.persistence.entity.exchange.ExchangeRateEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ExchangeRateDao { + // region Save + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(values: List) + // endregion + + // region Select + @Query("SELECT * FROM exchange_rates WHERE baseCurrency = :baseCurrency") + fun findAllByBaseCurrency(baseCurrency: String): Flow> + // endregion +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dao/exchange/ExchangeRateOverrideDao.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dao/exchange/ExchangeRateOverrideDao.kt new file mode 100644 index 0000000..97b6a35 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dao/exchange/ExchangeRateOverrideDao.kt @@ -0,0 +1,31 @@ +package com.ivy.core.persistence.dao.exchange + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.ivy.core.persistence.entity.exchange.ExchangeRateOverrideEntity +import com.ivy.data.DELETING +import kotlinx.coroutines.flow.Flow + +@Dao +interface ExchangeRateOverrideDao { + // region Save + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(values: List) + // endregion + + // region Select + @Query("SELECT * FROM exchange_rates_override WHERE sync != $DELETING") + suspend fun findAllBlocking(): List + + @Query("SELECT * FROM exchange_rates_override WHERE baseCurrency = :baseCurrency") + fun findAllByBaseCurrency(baseCurrency: String): Flow> + // endregion + @Query("DELETE FROM exchange_rates_override WHERE baseCurrency = :baseCurrency AND currency = :currency") + suspend fun deleteByBaseCurrencyAndCurrency( + baseCurrency: String, + currency: String + ) + +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dao/tag/TagDao.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dao/tag/TagDao.kt new file mode 100644 index 0000000..1b6e8cd --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dao/tag/TagDao.kt @@ -0,0 +1,20 @@ +package com.ivy.core.persistence.dao.tag + +import androidx.room.Dao +import androidx.room.Query +import com.ivy.core.persistence.entity.tag.TagEntity +import com.ivy.data.DELETING +import kotlinx.coroutines.flow.Flow + +@Dao +interface TagDao { + + // region Select + @Query("SELECT * FROM tags WHERE sync != $DELETING") + suspend fun findAllBlocking(): List + + @Query("SELECT * FROM tags WHERE id IN (:tagIds) AND sync != $DELETING") + fun findByTagIds(tagIds: List): Flow> + // endregion + +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dao/trn/AccountIdAndTrnTime.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dao/trn/AccountIdAndTrnTime.kt new file mode 100644 index 0000000..be8b811 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dao/trn/AccountIdAndTrnTime.kt @@ -0,0 +1,14 @@ +package com.ivy.core.persistence.dao.trn + +import androidx.room.ColumnInfo +import com.ivy.core.persistence.entity.trn.data.TrnTimeType +import java.time.Instant + +data class AccountIdAndTrnTime( + @ColumnInfo("accountId") + val accountId: String, + @ColumnInfo("time") + val time: Instant, + @ColumnInfo("timeType") + val timeType: TrnTimeType, +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dao/trn/SaveTrnData.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dao/trn/SaveTrnData.kt new file mode 100644 index 0000000..ee5b32a --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dao/trn/SaveTrnData.kt @@ -0,0 +1,13 @@ +package com.ivy.core.persistence.dao.trn + +import com.ivy.core.persistence.entity.attachment.AttachmentEntity +import com.ivy.core.persistence.entity.trn.TransactionEntity +import com.ivy.core.persistence.entity.trn.TrnMetadataEntity +import com.ivy.core.persistence.entity.trn.TrnTagEntity + +data class SaveTrnData( + val entity: TransactionEntity, + val tags: List, + val attachments: List, + val metadata: List, +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dao/trn/TransactionDao.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dao/trn/TransactionDao.kt new file mode 100644 index 0000000..830f9cb --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dao/trn/TransactionDao.kt @@ -0,0 +1,109 @@ +package com.ivy.core.persistence.dao.trn + +import androidx.room.* +import androidx.sqlite.db.SupportSQLiteQuery +import com.ivy.core.persistence.entity.attachment.AttachmentEntity +import com.ivy.core.persistence.entity.trn.TransactionEntity +import com.ivy.core.persistence.entity.trn.TrnMetadataEntity +import com.ivy.core.persistence.entity.trn.TrnTagEntity +import com.ivy.data.DELETING +import com.ivy.data.SyncState + +@Dao +abstract class TransactionDao { + // region Save + @Upsert + protected abstract suspend fun saveTrnEntity(entity: TransactionEntity) + + // region Tags + @Query("UPDATE trn_tags SET sync = :sync WHERE trnId = :trnId") + protected abstract suspend fun updateTrnTagsSyncByTrnId(trnId: String, sync: SyncState) + + @Upsert + protected abstract suspend fun saveTags(entity: List) + // endregion + + // region Attachments + @Query("UPDATE attachments SET sync = :sync WHERE associatedId = :associatedId") + protected abstract suspend fun updateAttachmentsSyncByAssociatedId( + associatedId: String, + sync: SyncState + ) + + @Upsert + protected abstract suspend fun saveAttachments(entity: List) + // endregion + + // region Metadata + @Query("UPDATE trn_metadata SET sync = :sync WHERE trnId = :trnId") + protected abstract suspend fun updateMetadataSyncByTrnId(trnId: String, sync: SyncState) + + @Upsert + protected abstract suspend fun saveMetadata(entity: List) + // endregion + + @Transaction + open suspend fun saveMany(trns: List) { + trns.forEach { save(it) } + } + + @Transaction + open suspend fun save(data: SaveTrnData) { + val trnId = data.entity.id + saveTrnEntity(data.entity) + + // Delete existing tags + updateTrnTagsSyncByTrnId(trnId, sync = SyncState.Deleting) + saveTags(data.tags) + + // Delete existing attachments + updateAttachmentsSyncByAssociatedId(trnId, sync = SyncState.Deleting) + saveAttachments(data.attachments) + + // Delete existing metadata key-values + updateMetadataSyncByTrnId(trnId, sync = SyncState.Deleting) + saveMetadata(data.metadata) + } + // endregion + + // region Select + @Query("SELECT * FROM transactions WHERE sync != $DELETING") + abstract suspend fun findAllBlocking(): List + + @RawQuery + abstract suspend fun findBySQL(query: SupportSQLiteQuery): List + + @Query( + "SELECT accountId, time, timeType FROM transactions WHERE id = :trnId" + + " AND sync = $DELETING LIMIT 1" + ) + abstract suspend fun findAccountIdAndTimeById(trnId: String): AccountIdAndTrnTime? + // endregion + + // region Delete + @Query("UPDATE transactions SET sync = :sync WHERE id = :trnId") + protected abstract suspend fun updateTrnEntitySyncById(trnId: String, sync: SyncState) + + @Transaction + open suspend fun markDeletedMany(trnIds: List) { + trnIds.forEach { markDeleted(it) } + } + + @Transaction + open suspend fun markDeleted(trnId: String) { + updateTrnEntitySyncById(trnId, sync = SyncState.Deleting) + updateTrnTagsSyncByTrnId(trnId, sync = SyncState.Deleting) + updateAttachmentsSyncByAssociatedId(trnId, sync = SyncState.Deleting) + updateMetadataSyncByTrnId(trnId, sync = SyncState.Deleting) + } + // endregion + + @Transaction + open suspend fun many( + toSave: List, + toDeleteTrnIds: List + ) { + markDeletedMany(trnIds = toDeleteTrnIds) + saveMany(trns = toSave) + } +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dao/trn/TrnLinkRecordDao.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dao/trn/TrnLinkRecordDao.kt new file mode 100644 index 0000000..b4707b5 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dao/trn/TrnLinkRecordDao.kt @@ -0,0 +1,38 @@ +package com.ivy.core.persistence.dao.trn + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.ivy.core.persistence.entity.trn.TrnLinkRecordEntity +import com.ivy.data.DELETING +import com.ivy.data.SyncState +import kotlinx.coroutines.flow.Flow + +@Dao +interface TrnLinkRecordDao { + // region Save + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(value: List) + // endregion + + // region Select + @Query("SELECT * FROM trn_links WHERE sync != $DELETING") + suspend fun findAllBlocking(): List + + @Query("SELECT * FROM trn_links WHERE sync != $DELETING") + fun findAll(): Flow> + + @Query("SELECT * FROM trn_links WHERE batchId = :batchId AND sync != $DELETING") + suspend fun findByBatchId(batchId: String): List + // endregion + + // region Update + @Query("UPDATE trn_links SET sync = :sync WHERE trnId = :trnId") + suspend fun updateSyncByTrnId(trnId: String, sync: SyncState) + + @Query("UPDATE trn_links SET sync = :sync WHERE trnId IN (:trnIds)") + suspend fun updateSyncByTrnIds(trnIds: List, sync: SyncState) + // endregion + +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dao/trn/TrnMetadataDao.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dao/trn/TrnMetadataDao.kt new file mode 100644 index 0000000..7197537 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dao/trn/TrnMetadataDao.kt @@ -0,0 +1,34 @@ +package com.ivy.core.persistence.dao.trn + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.ivy.core.persistence.entity.trn.TrnMetadataEntity +import com.ivy.data.DELETING +import com.ivy.data.SyncState +import kotlinx.coroutines.flow.Flow + +@Dao +interface TrnMetadataDao { + // region Save + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(value: TrnMetadataEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(value: List) + // endregion + + // region Select + @Query("SELECT * FROM trn_metadata WHERE sync != $DELETING") + suspend fun findAllBlocking(): List + + @Query("SELECT * FROM trn_metadata WHERE trnId = :trnId AND sync != $DELETING") + fun findByTrnId(trnId: String): Flow> + // endregion + + // region Update + @Query("UPDATE trn_metadata SET sync = :sync WHERE trnId = :trnId") + suspend fun updateSyncByTrnId(trnId: String, sync: SyncState) + // endregion +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dao/trn/TrnTagDao.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dao/trn/TrnTagDao.kt new file mode 100644 index 0000000..7db1492 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dao/trn/TrnTagDao.kt @@ -0,0 +1,35 @@ +package com.ivy.core.persistence.dao.trn + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.ivy.core.persistence.entity.trn.TrnTagEntity +import com.ivy.data.DELETING +import com.ivy.data.SyncState +import kotlinx.coroutines.flow.Flow + +@Dao +interface TrnTagDao { + // region Save + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(value: TrnTagEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(values: List) + // endregion + + // region Select + @Query("SELECT * FROM trn_tags WHERE sync != $DELETING") + suspend fun findAllBlocking(): List + + @Query("SELECT * FROM trn_tags WHERE trnId = :trnId AND sync != $DELETING") + fun findByTrnId(trnId: String): Flow> + // endregion + + + // region Update + @Query("UPDATE trn_tags SET sync = :sync WHERE trnId = :trnId") + suspend fun updateSyncByTrnId(trnId: String, sync: SyncState) + // endregion +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/datastore/IvyDataStore.kt b/core/persistence/src/main/java/com/ivy/core/persistence/datastore/IvyDataStore.kt new file mode 100644 index 0000000..f207ffe --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/datastore/IvyDataStore.kt @@ -0,0 +1,38 @@ +package com.ivy.core.persistence.datastore + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class IvyDataStore @Inject constructor( + @ApplicationContext private val appContext: Context +) { + private val Context.dataStore: DataStore by preferencesDataStore( + name = "ivy_wallet_datastore" + ) + + suspend fun put( + key: Preferences.Key, + value: T + ) { + appContext.dataStore.edit { + it[key] = value + } + } + + suspend fun remove(key: Preferences.Key) { + appContext.dataStore.edit { + it.remove(key = key) + } + } + + fun get(key: Preferences.Key): Flow = appContext.dataStore.data.map { it[key] } +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/datastore/keys/SettingsKeys.kt b/core/persistence/src/main/java/com/ivy/core/persistence/datastore/keys/SettingsKeys.kt new file mode 100644 index 0000000..2f25b3a --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/datastore/keys/SettingsKeys.kt @@ -0,0 +1,17 @@ +package com.ivy.core.persistence.datastore.keys + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SettingsKeys @Inject constructor() { + val baseCurrency by lazy { stringPreferencesKey(name = "base_currency") } + val startDayOfMonth by lazy { intPreferencesKey(name = "start_day_of_month") } + val hideBalance by lazy { booleanPreferencesKey(name = "hide_balance") } + val appLocked by lazy { booleanPreferencesKey(name = "app_locked") } + val displayName by lazy { stringPreferencesKey(name = "display_name") } + val theme by lazy { intPreferencesKey(name = "theme") } +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/di/CorePersistenceModuleDI.kt b/core/persistence/src/main/java/com/ivy/core/persistence/di/CorePersistenceModuleDI.kt new file mode 100644 index 0000000..d2ad5c0 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/di/CorePersistenceModuleDI.kt @@ -0,0 +1,82 @@ +package com.ivy.core.persistence.di + +import android.content.Context +import com.ivy.core.persistence.IvyWalletCoreDb +import com.ivy.core.persistence.algorithm.calc.RatesDao +import com.ivy.core.persistence.dao.AttachmentDao +import com.ivy.core.persistence.dao.account.AccountDao +import com.ivy.core.persistence.dao.account.AccountFolderDao +import com.ivy.core.persistence.dao.category.CategoryDao +import com.ivy.core.persistence.dao.exchange.ExchangeRateDao +import com.ivy.core.persistence.dao.exchange.ExchangeRateOverrideDao +import com.ivy.core.persistence.dao.tag.TagDao +import com.ivy.core.persistence.dao.trn.TransactionDao +import com.ivy.core.persistence.dao.trn.TrnLinkRecordDao +import com.ivy.core.persistence.dao.trn.TrnMetadataDao +import com.ivy.core.persistence.dao.trn.TrnTagDao +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) +object CorePersistenceModuleDI { + @Provides + @Singleton + fun provideIvyWalletDb(@ApplicationContext appContext: Context): IvyWalletCoreDb = + IvyWalletCoreDb.create(appContext) + + @Provides + @Singleton + fun provideAccountDao(db: IvyWalletCoreDb): AccountDao = db.accountDao() + + @Provides + @Singleton + fun provideAccountFolderDao(db: IvyWalletCoreDb): AccountFolderDao = db.accountFolderDao() + + @Provides + @Singleton + fun provideCategoryDao(db: IvyWalletCoreDb): CategoryDao = db.categoryDao() + + @Provides + @Singleton + fun provideExchangeRateDao(db: IvyWalletCoreDb): ExchangeRateDao = db.exchangeRateDao() + + @Provides + @Singleton + fun provideExchangeRateOverrideDao(db: IvyWalletCoreDb): ExchangeRateOverrideDao = + db.exchangeRateOverrideDao() + + @Provides + @Singleton + fun provideRatesDao(db:IvyWalletCoreDb) : RatesDao = + db.ratesDao() + + @Provides + @Singleton + fun provideTagDao(db: IvyWalletCoreDb): TagDao = db.tagDao() + + @Provides + @Singleton + fun provideTrnDao(db: IvyWalletCoreDb): TransactionDao = db.trnDao() + + @Provides + @Singleton + fun provideTrnLinkRecordDao(db: IvyWalletCoreDb): TrnLinkRecordDao = db.trnLinkRecordDao() + + @Provides + @Singleton + fun provideTrnMetadataDao(db: IvyWalletCoreDb): TrnMetadataDao = db.trnMetadataDao() + + @Provides + @Singleton + fun provideTrnTagDao(db: IvyWalletCoreDb): TrnTagDao = db.trnTagDao() + + @Provides + @Singleton + fun provideAttachmentDao(db: IvyWalletCoreDb): AttachmentDao = db.attachmentDao() +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dummy/account/DummyAccountEntity.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dummy/account/DummyAccountEntity.kt new file mode 100644 index 0000000..2226fad --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dummy/account/DummyAccountEntity.kt @@ -0,0 +1,33 @@ +package com.ivy.core.persistence.dummy.account + +import com.ivy.core.persistence.entity.account.AccountEntity +import com.ivy.data.SyncState +import com.ivy.data.account.AccountState +import java.time.Instant +import java.util.* + +fun dummyAccountEntity( + id: String = UUID.randomUUID().toString(), + name: String = "account", + currency: String = "currency", + color: Int = 123123, + icon: String? = "icon", + folderId: String? = null, + orderNum: Double = 0.0, + excluded: Boolean = false, + state: AccountState = AccountState.Default, + sync: SyncState = SyncState.Synced, + lastUpdated: Instant = Instant.now(), +): AccountEntity = AccountEntity( + id = id, + name = name, + currency = currency, + color = color, + icon = icon, + folderId = folderId, + orderNum = orderNum, + excluded = excluded, + state = state, + sync = sync, + lastUpdated = lastUpdated, +) diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dummy/account/DummyAccountFolderEntity.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dummy/account/DummyAccountFolderEntity.kt new file mode 100644 index 0000000..cd3b05d --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dummy/account/DummyAccountFolderEntity.kt @@ -0,0 +1,24 @@ +package com.ivy.core.persistence.dummy.account + +import com.ivy.core.persistence.entity.account.AccountFolderEntity +import com.ivy.data.SyncState +import java.time.Instant +import java.util.* + +fun dummyAccountFolderEntity( + id: String = UUID.randomUUID().toString(), + name: String = "Folder", + color: Int = 123123, + icon: String? = "icon", + orderNum: Double = 0.0, + sync: SyncState = SyncState.Synced, + lastUpdated: Instant = Instant.now(), +): AccountFolderEntity = AccountFolderEntity( + id = id, + name = name, + color = color, + icon = icon, + orderNum = orderNum, + sync = sync, + lastUpdated = lastUpdated, +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dummy/attachment/AttachmentEntityDummy.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dummy/attachment/AttachmentEntityDummy.kt new file mode 100644 index 0000000..7042fcd --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dummy/attachment/AttachmentEntityDummy.kt @@ -0,0 +1,28 @@ +package com.ivy.core.persistence.dummy.attachment + +import com.ivy.core.persistence.entity.attachment.AttachmentEntity +import com.ivy.data.SyncState +import com.ivy.data.attachment.AttachmentSource +import com.ivy.data.attachment.AttachmentType +import java.time.Instant +import java.util.* + +fun dummyAttachmentEntity( + id: String = UUID.randomUUID().toString(), + associatedId: String = UUID.randomUUID().toString(), + uri: String = "attachment", + source: AttachmentSource = AttachmentSource.Remote, + type: AttachmentType? = null, + filename: String? = null, + sync: SyncState = SyncState.Synced, + lastUpdated: Instant = Instant.now(), +): AttachmentEntity = AttachmentEntity( + id = id, + associatedId = associatedId, + uri = uri, + source = source, + type = type, + filename = filename, + sync = sync, + lastUpdated = lastUpdated, +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dummy/category/DummyCategoryEntity.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dummy/category/DummyCategoryEntity.kt new file mode 100644 index 0000000..9e5a814 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dummy/category/DummyCategoryEntity.kt @@ -0,0 +1,32 @@ +package com.ivy.core.persistence.dummy.category + +import com.ivy.core.persistence.entity.category.CategoryEntity +import com.ivy.data.SyncState +import com.ivy.data.category.CategoryState +import com.ivy.data.category.CategoryType +import java.time.Instant +import java.util.* + +fun dummyCategoryEntity( + id: String = UUID.randomUUID().toString(), + name: String = "Category", + color: Int = 123123, + icon: String? = "icon", + orderNum: Double = 0.0, + parentCategoryId: String? = null, + state: CategoryState = CategoryState.Default, + type: CategoryType = CategoryType.Both, + sync: SyncState = SyncState.Synced, + lastUpdated: Instant = Instant.now(), +) = CategoryEntity( + id = id, + name = name, + color = color, + icon = icon, + orderNum = orderNum, + parentCategoryId = parentCategoryId, + state = state, + type = type, + sync = sync, + lastUpdated = lastUpdated, +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dummy/exchange/DummyExchangeRateEntity.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dummy/exchange/DummyExchangeRateEntity.kt new file mode 100644 index 0000000..8b62b0a --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dummy/exchange/DummyExchangeRateEntity.kt @@ -0,0 +1,16 @@ +package com.ivy.core.persistence.dummy.exchange + +import com.ivy.core.persistence.entity.exchange.ExchangeRateEntity +import com.ivy.data.exchange.ExchangeProvider + +fun dummyExchangeRateEntity( + baseCurrency: String = "USD", + currency: String = "EUR", + rate: Double = 1.95, + provider: ExchangeProvider? = null, +) = ExchangeRateEntity( + baseCurrency = baseCurrency, + currency = currency, + rate = rate, + provider = provider +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dummy/exchange/DummyExchangeRateOverrideEntity.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dummy/exchange/DummyExchangeRateOverrideEntity.kt new file mode 100644 index 0000000..caad3af --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dummy/exchange/DummyExchangeRateOverrideEntity.kt @@ -0,0 +1,19 @@ +package com.ivy.core.persistence.dummy.exchange + +import com.ivy.core.persistence.entity.exchange.ExchangeRateOverrideEntity +import com.ivy.data.SyncState +import java.time.Instant + +fun dummyExchangeRateOverrideEntity( + baseCurrency: String = "USD", + currency: String = "EUR", + rate: Double = 1.95, + sync: SyncState = SyncState.Synced, + lastUpdated: Instant = Instant.now(), +) = ExchangeRateOverrideEntity( + baseCurrency = baseCurrency, + currency = currency, + rate = rate, + sync = sync, + lastUpdated = lastUpdated, +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dummy/trn/TrnEntityDummy.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dummy/trn/TrnEntityDummy.kt new file mode 100644 index 0000000..4a610f2 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dummy/trn/TrnEntityDummy.kt @@ -0,0 +1,44 @@ +package com.ivy.core.persistence.dummy.trn + +import com.ivy.core.persistence.entity.trn.TransactionEntity +import com.ivy.core.persistence.entity.trn.data.TrnTimeType +import com.ivy.data.CurrencyCode +import com.ivy.data.SyncState +import com.ivy.data.transaction.TransactionType +import com.ivy.data.transaction.TrnPurpose +import com.ivy.data.transaction.TrnState +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.* + +fun dummyTrnEntity( + id: String = UUID.randomUUID().toString(), + accountId: String = "", + type: TransactionType = TransactionType.Expense, + amount: Double = 0.0, + currency: CurrencyCode = "USD", + dateTime: Instant = Instant.now().truncatedTo(ChronoUnit.SECONDS), + dateTimeType: TrnTimeType = TrnTimeType.Actual, + title: String? = null, + description: String? = null, + categoryId: String? = null, + state: TrnState = TrnState.Default, + purpose: TrnPurpose? = null, + sync: SyncState = SyncState.Synced, + lastUpdated: Instant = Instant.now(), +): TransactionEntity = TransactionEntity( + id = id, + accountId = accountId, + type = type, + amount = amount, + currency = currency, + time = dateTime, + timeType = dateTimeType, + title = title, + description = description, + categoryId = categoryId, + state = state, + purpose = purpose, + sync = sync, + lastUpdated = lastUpdated, +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dummy/trn/TrnLinkRecordEntityDummy.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dummy/trn/TrnLinkRecordEntityDummy.kt new file mode 100644 index 0000000..95d33aa --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dummy/trn/TrnLinkRecordEntityDummy.kt @@ -0,0 +1,17 @@ +package com.ivy.core.persistence.dummy.trn + +import com.ivy.core.persistence.entity.trn.TrnLinkRecordEntity +import com.ivy.data.SyncState +import java.time.Instant +import java.util.* + +fun dummyTrnLinkRecordEntity( + id: String = UUID.randomUUID().toString(), + trnId: String = UUID.randomUUID().toString(), + batchId: String = UUID.randomUUID().toString(), + sync: SyncState = SyncState.Synced, + lastUpdated: Instant = Instant.now(), +) = TrnLinkRecordEntity( + id = id, trnId = trnId, batchId = batchId, sync = sync, + lastUpdated = lastUpdated +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/dummy/trn/TrnMetadataEntityDummy.kt b/core/persistence/src/main/java/com/ivy/core/persistence/dummy/trn/TrnMetadataEntityDummy.kt new file mode 100644 index 0000000..9726d75 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/dummy/trn/TrnMetadataEntityDummy.kt @@ -0,0 +1,18 @@ +package com.ivy.core.persistence.dummy.trn + +import com.ivy.core.persistence.entity.trn.TrnMetadataEntity +import com.ivy.data.SyncState +import java.time.Instant +import java.util.* + +fun dummyTrnMetadataEntity( + id: String = UUID.randomUUID().toString(), + trnId: String = UUID.randomUUID().toString(), + key: String = UUID.randomUUID().toString(), + value: String = UUID.randomUUID().toString(), + sync: SyncState = SyncState.Synced, + lastUpdated: Instant = Instant.now(), +) = TrnMetadataEntity( + id = id, trnId = trnId, key = key, value = value, sync = sync, + lastUpdated = lastUpdated +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/entity/account/AccountEntity.kt b/core/persistence/src/main/java/com/ivy/core/persistence/entity/account/AccountEntity.kt new file mode 100644 index 0000000..42bd3c4 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/entity/account/AccountEntity.kt @@ -0,0 +1,36 @@ +package com.ivy.core.persistence.entity.account + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.ivy.data.SyncState +import com.ivy.data.account.AccountState +import java.time.Instant + + +@Entity(tableName = "accounts") +data class AccountEntity( + @PrimaryKey + @ColumnInfo(name = "id", index = true) + val id: String, + @ColumnInfo(name = "name") + val name: String, + @ColumnInfo(name = "currency") + val currency: String, + @ColumnInfo(name = "color") + val color: Int, + @ColumnInfo(name = "icon") + val icon: String?, + @ColumnInfo(name = "folderId", index = true) + val folderId: String?, + @ColumnInfo(name = "orderNum", index = true) + val orderNum: Double, + @ColumnInfo(name = "excluded") + val excluded: Boolean, + @ColumnInfo(name = "state", index = true) + val state: AccountState, + @ColumnInfo(name = "sync", index = true) + val sync: SyncState, + @ColumnInfo(name = "last_updated") + val lastUpdated: Instant, +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/entity/account/AccountFolderEntity.kt b/core/persistence/src/main/java/com/ivy/core/persistence/entity/account/AccountFolderEntity.kt new file mode 100644 index 0000000..fc8eacb --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/entity/account/AccountFolderEntity.kt @@ -0,0 +1,28 @@ +package com.ivy.core.persistence.entity.account + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.ivy.data.SyncState +import java.time.Instant + +@Entity(tableName = "account_folders") +data class AccountFolderEntity( + @PrimaryKey + @ColumnInfo(name = "id", index = true) + val id: String, + + @ColumnInfo(name = "name") + val name: String, + @ColumnInfo(name = "color") + val color: Int, + @ColumnInfo(name = "icon") + val icon: String?, + @ColumnInfo(name = "orderNum", index = true) + val orderNum: Double, + + @ColumnInfo(name = "sync", index = true) + val sync: SyncState, + @ColumnInfo(name = "last_updated") + val lastUpdated: Instant, +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/entity/account/converter/AccountTypeConverter.kt b/core/persistence/src/main/java/com/ivy/core/persistence/entity/account/converter/AccountTypeConverter.kt new file mode 100644 index 0000000..02d0511 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/entity/account/converter/AccountTypeConverter.kt @@ -0,0 +1,15 @@ +package com.ivy.core.persistence.entity.account.converter + +import androidx.room.TypeConverter +import com.ivy.data.account.AccountState + +class AccountTypeConverter { + // region AccountState + @TypeConverter + fun ser(state: AccountState): Int = state.code + + @TypeConverter + fun accountState(code: Int): AccountState = + AccountState.fromCode(code) ?: AccountState.Default + // endregion +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/entity/attachment/AttachmentEntity.kt b/core/persistence/src/main/java/com/ivy/core/persistence/entity/attachment/AttachmentEntity.kt new file mode 100644 index 0000000..bd7b1e4 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/entity/attachment/AttachmentEntity.kt @@ -0,0 +1,31 @@ +package com.ivy.core.persistence.entity.attachment + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.ivy.data.SyncState +import com.ivy.data.attachment.AttachmentSource +import com.ivy.data.attachment.AttachmentType +import java.time.Instant + +@Entity(tableName = "attachments") +data class AttachmentEntity( + @PrimaryKey + @ColumnInfo(name = "id") + val id: String, + @ColumnInfo(name = "associatedId", index = true) + val associatedId: String, + @ColumnInfo(name = "uri") + val uri: String, + @ColumnInfo(name = "source") + val source: AttachmentSource, + @ColumnInfo(name = "filename") + val filename: String?, + @ColumnInfo(name = "type") + val type: AttachmentType?, + + @ColumnInfo(name = "sync", index = true) + val sync: SyncState, + @ColumnInfo(name = "last_updated") + val lastUpdated: Instant, +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/entity/attachment/converter/AttachmentTypeConverters.kt b/core/persistence/src/main/java/com/ivy/core/persistence/entity/attachment/converter/AttachmentTypeConverters.kt new file mode 100644 index 0000000..04a2a6b --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/entity/attachment/converter/AttachmentTypeConverters.kt @@ -0,0 +1,24 @@ +package com.ivy.core.persistence.entity.attachment.converter + +import androidx.room.TypeConverter +import com.ivy.data.attachment.AttachmentSource +import com.ivy.data.attachment.AttachmentType + +class AttachmentTypeConverters { + + // region AttachmentType + @TypeConverter + fun ser(attachmentType: AttachmentType?): Int? = attachmentType?.code + + @TypeConverter + fun attachmentType(code: Int?): AttachmentType? = code?.let(AttachmentType::fromCode) + // endregion + + // region AttachmentSource + @TypeConverter + fun ser(source: AttachmentSource): Int = source.code + + @TypeConverter + fun attachmentSource(code: Int): AttachmentSource = AttachmentSource.fromCode(code)!! + // endregion +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/entity/category/CategoryEntity.kt b/core/persistence/src/main/java/com/ivy/core/persistence/entity/category/CategoryEntity.kt new file mode 100644 index 0000000..02b02bf --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/entity/category/CategoryEntity.kt @@ -0,0 +1,34 @@ +package com.ivy.core.persistence.entity.category + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.ivy.data.SyncState +import com.ivy.data.category.CategoryState +import com.ivy.data.category.CategoryType +import java.time.Instant + +@Entity(tableName = "categories") +data class CategoryEntity( + @PrimaryKey + @ColumnInfo(name = "id", index = true) + val id: String, + @ColumnInfo(name = "name") + val name: String, + @ColumnInfo(name = "color") + val color: Int, + @ColumnInfo(name = "icon") + val icon: String?, + @ColumnInfo(name = "orderNum", index = true) + val orderNum: Double, + @ColumnInfo(name = "parentCategoryId", index = true) + val parentCategoryId: String?, + @ColumnInfo(name = "type", index = true) + val type: CategoryType, + @ColumnInfo(name = "state", index = true) + val state: CategoryState, + @ColumnInfo(name = "sync", index = true) + val sync: SyncState, + @ColumnInfo(name = "last_updated") + val lastUpdated: Instant, +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/entity/category/converter/CategoryTypeConverter.kt b/core/persistence/src/main/java/com/ivy/core/persistence/entity/category/converter/CategoryTypeConverter.kt new file mode 100644 index 0000000..7db2dd1 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/entity/category/converter/CategoryTypeConverter.kt @@ -0,0 +1,24 @@ +package com.ivy.core.persistence.entity.category.converter + +import androidx.room.TypeConverter +import com.ivy.data.category.CategoryState +import com.ivy.data.category.CategoryType + +class CategoryTypeConverter { + // region CategoryState + @TypeConverter + fun ser(state: CategoryState): Int = state.code + + @TypeConverter + fun categoryState(code: Int): CategoryState = + CategoryState.fromCode(code) ?: CategoryState.Default + // endregion + + // region CategoryType + @TypeConverter + fun ser(type: CategoryType): Int = type.code + + @TypeConverter + fun categoryType(code: Int): CategoryType = CategoryType.fromCode(code)!! + // endregion +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/entity/exchange/ExchangeRateEntity.kt b/core/persistence/src/main/java/com/ivy/core/persistence/entity/exchange/ExchangeRateEntity.kt new file mode 100644 index 0000000..48c42cd --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/entity/exchange/ExchangeRateEntity.kt @@ -0,0 +1,20 @@ +package com.ivy.core.persistence.entity.exchange + +import androidx.room.ColumnInfo +import androidx.room.Entity +import com.ivy.data.exchange.ExchangeProvider + +@Entity( + tableName = "exchange_rates", + primaryKeys = ["baseCurrency", "currency"] +) +data class ExchangeRateEntity( + @ColumnInfo(name = "baseCurrency", index = true) + val baseCurrency: String, + @ColumnInfo(name = "currency", index = true) + val currency: String, + @ColumnInfo(name = "rate") + val rate: Double, + @ColumnInfo(name = "provider", index = true) + val provider: ExchangeProvider?, +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/entity/exchange/ExchangeRateOverrideEntity.kt b/core/persistence/src/main/java/com/ivy/core/persistence/entity/exchange/ExchangeRateOverrideEntity.kt new file mode 100644 index 0000000..afaafea --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/entity/exchange/ExchangeRateOverrideEntity.kt @@ -0,0 +1,24 @@ +package com.ivy.core.persistence.entity.exchange + +import androidx.room.ColumnInfo +import androidx.room.Entity +import com.ivy.data.SyncState +import java.time.Instant + +@Entity( + tableName = "exchange_rates_override", + primaryKeys = ["baseCurrency", "currency"] +) +data class ExchangeRateOverrideEntity( + @ColumnInfo(name = "baseCurrency", index = true) + val baseCurrency: String, + @ColumnInfo(name = "currency", index = true) + val currency: String, + @ColumnInfo(name = "rate") + val rate: Double, + + @ColumnInfo(name = "sync", index = true) + val sync: SyncState, + @ColumnInfo(name = "last_updated") + val lastUpdated: Instant, +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/entity/exchange/converter/ExchangeRateTypeConverter.kt b/core/persistence/src/main/java/com/ivy/core/persistence/entity/exchange/converter/ExchangeRateTypeConverter.kt new file mode 100644 index 0000000..5273926 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/entity/exchange/converter/ExchangeRateTypeConverter.kt @@ -0,0 +1,15 @@ +package com.ivy.core.persistence.entity.exchange.converter + +import androidx.room.TypeConverter +import com.ivy.data.exchange.ExchangeProvider + +class ExchangeRateTypeConverter { + // region ExchangeProvider + @TypeConverter + fun ser(provider: ExchangeProvider?): Int? = provider?.code + + @TypeConverter + fun exchangeProvider(code: Int?): ExchangeProvider? = + code?.let(ExchangeProvider::fromCode) + // endregion +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/entity/tag/TagEntity.kt b/core/persistence/src/main/java/com/ivy/core/persistence/entity/tag/TagEntity.kt new file mode 100644 index 0000000..d4813f3 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/entity/tag/TagEntity.kt @@ -0,0 +1,29 @@ +package com.ivy.core.persistence.entity.tag + +import androidx.annotation.ColorInt +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.ivy.data.SyncState +import com.ivy.data.tag.TagState +import java.time.Instant + +@Entity(tableName = "tags") +data class TagEntity( + @PrimaryKey + @ColumnInfo(name = "id", index = true) + val id: String, + @ColorInt + @ColumnInfo(name = "color") + val color: Int, + @ColumnInfo(name = "name") + val name: String, + @ColumnInfo(name = "orderNum", index = true) + val orderNum: Double, + @ColumnInfo(name = "state", index = true) + val state: TagState, + @ColumnInfo(name = "sync", index = true) + val sync: SyncState, + @ColumnInfo(name = "last_updated") + val lastUpdated: Instant, +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/entity/tag/converter/TagTypeConverter.kt b/core/persistence/src/main/java/com/ivy/core/persistence/entity/tag/converter/TagTypeConverter.kt new file mode 100644 index 0000000..b6c9772 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/entity/tag/converter/TagTypeConverter.kt @@ -0,0 +1,14 @@ +package com.ivy.core.persistence.entity.tag.converter + +import androidx.room.TypeConverter +import com.ivy.data.tag.TagState + +class TagTypeConverter { + // region TagState + @TypeConverter + fun ser(state: TagState): Int = state.code + + @TypeConverter + fun tagState(code: Int): TagState = TagState.fromCode(code)!! + // endregion +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/entity/trn/TransactionEntity.kt b/core/persistence/src/main/java/com/ivy/core/persistence/entity/trn/TransactionEntity.kt new file mode 100644 index 0000000..ada4f48 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/entity/trn/TransactionEntity.kt @@ -0,0 +1,71 @@ +package com.ivy.core.persistence.entity.trn + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.ivy.core.persistence.entity.trn.data.TrnTimeType +import com.ivy.data.CurrencyCode +import com.ivy.data.SyncState +import com.ivy.data.transaction.TransactionType +import com.ivy.data.transaction.TrnPurpose +import com.ivy.data.transaction.TrnState +import java.time.Instant + +@Entity(tableName = "transactions") +data class TransactionEntity( + @PrimaryKey + @ColumnInfo(name = "id", index = true) + val id: String, + + // region Mandatory + @ColumnInfo(name = "accountId", index = true) + val accountId: String, + @ColumnInfo(name = "type", index = true) + val type: TransactionType, + @ColumnInfo(name = "amount") + val amount: Double, + @ColumnInfo(name = "currency") + val currency: CurrencyCode, + @ColumnInfo(name = "time", index = true) + val time: Instant, + /** + * actual (happened) or due (planned) + */ + @ColumnInfo(name = "timeType", index = true) + val timeType: TrnTimeType, + // endregion + + // region Optional + @ColumnInfo(name = "title") + val title: String?, + @ColumnInfo(name = "description") + val description: String?, + @ColumnInfo(name = "categoryId", index = true) + val categoryId: String?, + /** + * attachments are handled via + * [com.ivy.core.persistence.entity.attachment.AttachmentEntity] + */ + // endregion + + // region Metadata + /** + * transactions are linked together (batched) via + * [TrnLinkRecordEntity] + */ + + /** + * additional transaction metadata is stored in + * [TrnMetadataEntity] + */ + + @ColumnInfo(name = "state") + val state: TrnState, + @ColumnInfo(name = "purpose") + val purpose: TrnPurpose?, + @ColumnInfo(name = "sync", index = true) + val sync: SyncState, + @ColumnInfo(name = "last_updated") + val lastUpdated: Instant, + // endregion +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/entity/trn/TrnLinkRecordEntity.kt b/core/persistence/src/main/java/com/ivy/core/persistence/entity/trn/TrnLinkRecordEntity.kt new file mode 100644 index 0000000..b3464f6 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/entity/trn/TrnLinkRecordEntity.kt @@ -0,0 +1,26 @@ +package com.ivy.core.persistence.entity.trn + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.ivy.data.SyncState +import java.time.Instant + +@Entity(tableName = "trn_links") +data class TrnLinkRecordEntity( + /** + * record id for uniqueness in the records table + */ + @PrimaryKey + @ColumnInfo(name = "id", index = true) + val id: String, + @ColumnInfo(name = "trnId", index = true) + val trnId: String, + @ColumnInfo(name = "batchId") + val batchId: String, + + @ColumnInfo(name = "sync", index = true) + val sync: SyncState, + @ColumnInfo(name = "last_updated") + val lastUpdated: Instant, +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/entity/trn/TrnMetadataEntity.kt b/core/persistence/src/main/java/com/ivy/core/persistence/entity/trn/TrnMetadataEntity.kt new file mode 100644 index 0000000..64782c9 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/entity/trn/TrnMetadataEntity.kt @@ -0,0 +1,28 @@ +package com.ivy.core.persistence.entity.trn + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.ivy.data.SyncState +import java.time.Instant + +@Entity(tableName = "trn_metadata") +data class TrnMetadataEntity( + /** + * record id for uniqueness in the records table + */ + @PrimaryKey + @ColumnInfo(name = "id", index = true) + val id: String, + @ColumnInfo(name = "trnId", index = true) + val trnId: String, + @ColumnInfo(name = "key", index = true) + val key: String, + @ColumnInfo(name = "value", index = true) + val value: String, + + @ColumnInfo(name = "sync", index = true) + val sync: SyncState, + @ColumnInfo(name = "last_updated") + val lastUpdated: Instant, +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/entity/trn/TrnTagEntity.kt b/core/persistence/src/main/java/com/ivy/core/persistence/entity/trn/TrnTagEntity.kt new file mode 100644 index 0000000..2b78267 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/entity/trn/TrnTagEntity.kt @@ -0,0 +1,18 @@ +package com.ivy.core.persistence.entity.trn + +import androidx.room.ColumnInfo +import androidx.room.Entity +import com.ivy.data.SyncState +import java.time.Instant + +@Entity(tableName = "trn_tags", primaryKeys = ["trnId", "tagId"]) +data class TrnTagEntity( + @ColumnInfo(name = "trnId", index = true) + val trnId: String, + @ColumnInfo(name = "tagId", index = true) + val tagId: String, + @ColumnInfo(name = "sync", index = true) + val sync: SyncState, + @ColumnInfo(name = "last_updated") + val lastUpdated: Instant, +) \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/entity/trn/converter/TrnTypeConverters.kt b/core/persistence/src/main/java/com/ivy/core/persistence/entity/trn/converter/TrnTypeConverters.kt new file mode 100644 index 0000000..022cc32 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/entity/trn/converter/TrnTypeConverters.kt @@ -0,0 +1,34 @@ +package com.ivy.core.persistence.entity.trn.converter + +import androidx.room.TypeConverter +import com.ivy.core.persistence.entity.trn.data.TrnTimeType +import com.ivy.data.transaction.TransactionType +import com.ivy.data.transaction.TrnPurpose + +class TrnTypeConverters { + + // region BatchPurpose + @TypeConverter + fun ser(purpose: TrnPurpose?): Int? = purpose?.code + + @TypeConverter + fun purpose(code: Int?): TrnPurpose? = code?.let(TrnPurpose::fromCode) + // endregion + + // region TrnType + @TypeConverter + fun ser(type: TransactionType): Int = type.code + + @TypeConverter + fun trnType(code: Int): TransactionType = TransactionType.fromCode(code)!! + // endregion + + // region TrnTimeType + @TypeConverter + fun ser(timeType: TrnTimeType): Int = timeType.code + + @TypeConverter + fun trnTimeType(code: Int): TrnTimeType = + TrnTimeType.fromCode(code) ?: TrnTimeType.Actual + // endregion +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/entity/trn/data/TrnTimeType.kt b/core/persistence/src/main/java/com/ivy/core/persistence/entity/trn/data/TrnTimeType.kt new file mode 100644 index 0000000..2265b93 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/entity/trn/data/TrnTimeType.kt @@ -0,0 +1,12 @@ +package com.ivy.core.persistence.entity.trn.data + +const val ActualCode = 1 +const val DueCode = 2 + +enum class TrnTimeType(val code: Int) { + Actual(ActualCode), Due(DueCode); + + companion object { + fun fromCode(code: Int): TrnTimeType? = values().firstOrNull { it.code == code } + } +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/migration/Migration1to2_LastUpdated.kt b/core/persistence/src/main/java/com/ivy/core/persistence/migration/Migration1to2_LastUpdated.kt new file mode 100644 index 0000000..b963110 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/migration/Migration1to2_LastUpdated.kt @@ -0,0 +1,28 @@ +package com.ivy.core.persistence.migration + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +class Migration1to2_LastUpdated : Migration(1, 2) { + override fun migrate(database: SupportSQLiteDatabase) { + val tables = setOf( + "accounts", + "account_folders", + "attachments", + "categories", + "exchange_rates_override", + "tags", + "transactions", + "trn_links", + "trn_metadata", + "trn_tags", + ) + tables.forEach { + database.addLastUpdated(tableName = it) + } + } + + private fun SupportSQLiteDatabase.addLastUpdated(tableName: String) { + execSQL("ALTER TABLE $tableName ADD COLUMN last_updated INTEGER NOT NULL DEFAULT 0") + } +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/query/TrnQueryExecutor.kt b/core/persistence/src/main/java/com/ivy/core/persistence/query/TrnQueryExecutor.kt new file mode 100644 index 0000000..1ff8c41 --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/query/TrnQueryExecutor.kt @@ -0,0 +1,23 @@ +package com.ivy.core.persistence.query + +import androidx.sqlite.db.SimpleSQLiteQuery +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.persistence.dao.trn.TransactionDao +import com.ivy.core.persistence.entity.trn.TransactionEntity +import javax.inject.Inject + +class TrnQueryExecutor @Inject constructor( + private val transactionDao: TransactionDao, + private val timeProvider: TimeProvider +) { + suspend fun query(where: TrnWhere): List { + val whereClause = generateWhereClause(where, timeProvider = timeProvider) + return transactionDao.findBySQL( + SimpleSQLiteQuery( + "SELECT * FROM transactions WHERE ${whereClause.query}" + + " ORDER BY timeType DESC, time DESC", + whereClause.args.toTypedArray() + ) + ) + } +} \ No newline at end of file diff --git a/core/persistence/src/main/java/com/ivy/core/persistence/query/TrnWhere.kt b/core/persistence/src/main/java/com/ivy/core/persistence/query/TrnWhere.kt new file mode 100644 index 0000000..8a35c9c --- /dev/null +++ b/core/persistence/src/main/java/com/ivy/core/persistence/query/TrnWhere.kt @@ -0,0 +1,178 @@ +package com.ivy.core.persistence.query + +import arrow.core.NonEmptyList +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.toEpochSeconds +import com.ivy.common.time.toPair +import com.ivy.core.persistence.entity.trn.data.TrnTimeType +import com.ivy.core.persistence.query.TrnWhere.* +import com.ivy.data.SyncState +import com.ivy.data.time.TimeRange +import com.ivy.data.transaction.TransactionType +import com.ivy.data.transaction.TrnPurpose +import java.time.LocalDateTime + +sealed interface TrnWhere { + data class ById(val id: String) : TrnWhere + data class ByIdIn(val ids: NonEmptyList) : TrnWhere + + data class ByCategoryId(val categoryId: String?) : TrnWhere + data class ByCategoryIdIn(val categoryIds: NonEmptyList) : TrnWhere + + data class ByAccountId(val accountId: String) : TrnWhere + data class ByAccountIdIn(val accountIds: NonEmptyList) : TrnWhere + + data class ByType(val trnType: TransactionType) : TrnWhere + data class ByTypeIn(val types: NonEmptyList) : TrnWhere + + data class BySync(val sync: SyncState) : TrnWhere + data class ByPurpose(val purpose: TrnPurpose?) : TrnWhere + data class ByPurposeIn(val purposes: NonEmptyList) : TrnWhere + + /** + * Inclusive period [from, to] + */ + data class DueBetween(val range: TimeRange) : TrnWhere + + /** + * Inclusive period [from, to] + */ + data class ActualBetween(val range: TimeRange) : TrnWhere + + data class Brackets(val cond: TrnWhere) : TrnWhere + data class And(val cond1: TrnWhere, val cond2: TrnWhere) : TrnWhere + data class Or(val cond1: TrnWhere, val cond2: TrnWhere) : TrnWhere + data class Not(val cond: TrnWhere) : TrnWhere +} + +fun brackets(cond: TrnWhere): Brackets = Brackets(cond) +infix fun TrnWhere.and(cond2: TrnWhere): And = And(this, cond2) +infix fun TrnWhere.or(cond2: TrnWhere): Or = Or(this, cond2) +fun not(cond: TrnWhere): Not = Not(cond) + +data class WhereClause( + val query: String, + val args: List +) + +private object EmptyArg + +internal fun generateWhereClause( + where: TrnWhere, + timeProvider: TimeProvider +): WhereClause { + fun placeholders(argsCount: Int): String = when (argsCount) { + 0 -> "" + 1 -> "?" + else -> "?, " + placeholders(argsCount - 1) + } + + fun arg(arg: T): List = listOf(arg) + fun noArg() = arg(EmptyArg) + + fun timestamp(dateTime: LocalDateTime): Long = + dateTime.toEpochSeconds(timeProvider) + + fun trnType(type: TransactionType): Int = type.code + + val result = when (where) { + is ById -> "id = ?" to arg(where.id) + is ByIdIn -> + "id IN (${placeholders(where.ids.size)})" to arg(where.ids.toList()) + + is ByType -> "type = ?" to arg(trnType(where.trnType)) + is ByTypeIn -> + "type IN (${placeholders(where.types.size)})" to arg( + where.types.map(::trnType).toList() + ) + + is BySync -> "sync = ?" to arg(where.sync.code) + + is ByPurpose -> where.purpose?.let { + "purpose = ?" to arg(where.purpose.code) + } ?: ("purpose IS NULL" to noArg()) + is ByPurposeIn -> + "purpose IN (${placeholders(where.purposes.size)})" to arg( + where.purposes.map { it.code }.toList() + ) + + is ByAccountId -> "accountId = ?" to arg(where.accountId) + is ByAccountIdIn -> + "accountId IN (${placeholders(where.accountIds.size)})" to arg( + where.accountIds.toList() + ) + + is ByCategoryId -> { + where.categoryId?.let { + "categoryId = ?" to arg(it) + } ?: ("categoryId IS NULL" to noArg()) + } + is ByCategoryIdIn -> { + val nonNullArgs = where.categoryIds.filterNotNull() + when (nonNullArgs.size) { + 0 -> "categoryId IS NULL" to nonNullArgs + where.categoryIds.size -> + // only non-null args + "categoryId IN (${placeholders(nonNullArgs.size)})" to + arg(nonNullArgs) + else -> + // non-null args + null + "(categoryId IN (${placeholders(nonNullArgs.size)}) OR categoryId IS NULL)" to + arg(nonNullArgs) + } + } + + is DueBetween -> { + "(timeType = ${TrnTimeType.Due.code} AND time >= ? AND time <= ?)" to arg( + where.range.toPair().toList().map(::timestamp) + ) + } + is ActualBetween -> + "(timeType = ${TrnTimeType.Actual.code} AND time >= ? AND time <= ?)" to arg( + where.range.toPair().toList().map(::timestamp) + ) + + is Brackets -> { + val clause = generateWhereClause(where.cond, timeProvider) + "(${clause.query})" to clause.args + } + is And -> { + val clause1 = generateWhereClause(where.cond1, timeProvider) + val clause2 = generateWhereClause(where.cond2, timeProvider) + + "${clause1.query} AND ${clause2.query}" to (clause1.args + clause2.args) + } + is Or -> { + val clause1 = generateWhereClause(where.cond1, timeProvider) + val clause2 = generateWhereClause(where.cond2, timeProvider) + + "${clause1.query} OR ${clause2.query}" to (clause1.args + clause2.args) + } + is Not -> { + val clause = generateWhereClause(where.cond, timeProvider) + "NOT(${clause.query})" to clause.args + } + } + + val args = flatten(result.second.filter { it !is EmptyArg }) + + return WhereClause( + query = result.first, + args = args + ) +} + +@Suppress("UNCHECKED_CAST") +private fun flatten(list: List): List { + val result = mutableListOf() + + for (item in list) { + if (item is List<*>) { + result.addAll(item as List) + } else { + result.add(item) + } + } + + return result +} \ No newline at end of file diff --git a/core/room-db-schemas/README.md b/core/room-db-schemas/README.md new file mode 100644 index 0000000..8bcfc74 --- /dev/null +++ b/core/room-db-schemas/README.md @@ -0,0 +1,3 @@ +# Room DB Schemas + +Auto-exported JSON schemas of the Room Database for every version. \ No newline at end of file diff --git a/core/room-db-schemas/com.ivy.core.persistence.IvyWalletCoreDb/1.json b/core/room-db-schemas/com.ivy.core.persistence.IvyWalletCoreDb/1.json new file mode 100644 index 0000000..335beca --- /dev/null +++ b/core/room-db-schemas/com.ivy.core.persistence.IvyWalletCoreDb/1.json @@ -0,0 +1,1004 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "57157e79e5344cd9fcd3177d22c70523", + "entities": [ + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `type` INTEGER NOT NULL, `amount` REAL NOT NULL, `currency` TEXT NOT NULL, `time` INTEGER NOT NULL, `timeType` INTEGER NOT NULL, `title` TEXT, `description` TEXT, `categoryId` TEXT, `state` TEXT NOT NULL, `purpose` INTEGER, `sync` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeType", + "columnName": "timeType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_transactions_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_transactions_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_transactions_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_transactions_time", + "unique": false, + "columnNames": [ + "time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_time` ON `${TABLE_NAME}` (`time`)" + }, + { + "name": "index_transactions_timeType", + "unique": false, + "columnNames": [ + "timeType" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_timeType` ON `${TABLE_NAME}` (`timeType`)" + }, + { + "name": "index_transactions_categoryId", + "unique": false, + "columnNames": [ + "categoryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_categoryId` ON `${TABLE_NAME}` (`categoryId`)" + }, + { + "name": "index_transactions_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "trn_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `trnId` TEXT NOT NULL, `batchId` TEXT NOT NULL, `sync` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trnId", + "columnName": "trnId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "batchId", + "columnName": "batchId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_trn_links_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_links_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_trn_links_trnId", + "unique": false, + "columnNames": [ + "trnId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_links_trnId` ON `${TABLE_NAME}` (`trnId`)" + }, + { + "name": "index_trn_links_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_links_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "trn_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `trnId` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, `sync` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trnId", + "columnName": "trnId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_trn_metadata_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_trn_metadata_trnId", + "unique": false, + "columnNames": [ + "trnId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_trnId` ON `${TABLE_NAME}` (`trnId`)" + }, + { + "name": "index_trn_metadata_key", + "unique": false, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_key` ON `${TABLE_NAME}` (`key`)" + }, + { + "name": "index_trn_metadata_value", + "unique": false, + "columnNames": [ + "value" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_value` ON `${TABLE_NAME}` (`value`)" + }, + { + "name": "index_trn_metadata_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "attachments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `associatedId` TEXT NOT NULL, `uri` TEXT NOT NULL, `source` INTEGER NOT NULL, `filename` TEXT, `type` INTEGER, `sync` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "associatedId", + "columnName": "associatedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_attachments_associatedId", + "unique": false, + "columnNames": [ + "associatedId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_associatedId` ON `${TABLE_NAME}` (`associatedId`)" + }, + { + "name": "index_attachments_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `currency` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `folderId` TEXT, `orderNum` REAL NOT NULL, `excluded` INTEGER NOT NULL, `state` INTEGER NOT NULL, `sync` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "excluded", + "columnName": "excluded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_accounts_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_accounts_folderId", + "unique": false, + "columnNames": [ + "folderId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_folderId` ON `${TABLE_NAME}` (`folderId`)" + }, + { + "name": "index_accounts_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_accounts_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_accounts_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "account_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `sync` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_account_folders_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_folders_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_account_folders_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_folders_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_account_folders_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_folders_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `parentCategoryId` TEXT, `type` INTEGER NOT NULL, `state` INTEGER NOT NULL, `sync` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "parentCategoryId", + "columnName": "parentCategoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_categories_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_categories_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_categories_parentCategoryId", + "unique": false, + "columnNames": [ + "parentCategoryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_parentCategoryId` ON `${TABLE_NAME}` (`parentCategoryId`)" + }, + { + "name": "index_categories_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_categories_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_categories_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, `provider` INTEGER, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "baseCurrency", + "currency" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_exchange_rates_baseCurrency", + "unique": false, + "columnNames": [ + "baseCurrency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_baseCurrency` ON `${TABLE_NAME}` (`baseCurrency`)" + }, + { + "name": "index_exchange_rates_currency", + "unique": false, + "columnNames": [ + "currency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_currency` ON `${TABLE_NAME}` (`currency`)" + }, + { + "name": "index_exchange_rates_provider", + "unique": false, + "columnNames": [ + "provider" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_provider` ON `${TABLE_NAME}` (`provider`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates_override", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, `sync` INTEGER NOT NULL, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "baseCurrency", + "currency" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_exchange_rates_override_baseCurrency", + "unique": false, + "columnNames": [ + "baseCurrency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_override_baseCurrency` ON `${TABLE_NAME}` (`baseCurrency`)" + }, + { + "name": "index_exchange_rates_override_currency", + "unique": false, + "columnNames": [ + "currency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_override_currency` ON `${TABLE_NAME}` (`currency`)" + }, + { + "name": "index_exchange_rates_override_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_override_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `color` INTEGER NOT NULL, `name` TEXT NOT NULL, `orderNum` REAL NOT NULL, `state` TEXT NOT NULL, `sync` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_tags_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_tags_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_tags_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_tags_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "trn_tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trnId` TEXT NOT NULL, `tagId` TEXT NOT NULL, `sync` INTEGER NOT NULL, PRIMARY KEY(`trnId`, `tagId`))", + "fields": [ + { + "fieldPath": "trnId", + "columnName": "trnId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tagId", + "columnName": "tagId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "trnId", + "tagId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_trn_tags_trnId", + "unique": false, + "columnNames": [ + "trnId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_tags_trnId` ON `${TABLE_NAME}` (`trnId`)" + }, + { + "name": "index_trn_tags_tagId", + "unique": false, + "columnNames": [ + "tagId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_tags_tagId` ON `${TABLE_NAME}` (`tagId`)" + }, + { + "name": "index_trn_tags_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_tags_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '57157e79e5344cd9fcd3177d22c70523')" + ] + } +} \ No newline at end of file diff --git a/core/room-db-schemas/com.ivy.core.persistence.IvyWalletCoreDb/2.json b/core/room-db-schemas/com.ivy.core.persistence.IvyWalletCoreDb/2.json new file mode 100644 index 0000000..8224f49 --- /dev/null +++ b/core/room-db-schemas/com.ivy.core.persistence.IvyWalletCoreDb/2.json @@ -0,0 +1,1064 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "d959ed54671eafea046647bad1f91748", + "entities": [ + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `type` INTEGER NOT NULL, `amount` REAL NOT NULL, `currency` TEXT NOT NULL, `time` INTEGER NOT NULL, `timeType` INTEGER NOT NULL, `title` TEXT, `description` TEXT, `categoryId` TEXT, `state` TEXT NOT NULL, `purpose` INTEGER, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeType", + "columnName": "timeType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_transactions_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_transactions_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_transactions_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_transactions_time", + "unique": false, + "columnNames": [ + "time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_time` ON `${TABLE_NAME}` (`time`)" + }, + { + "name": "index_transactions_timeType", + "unique": false, + "columnNames": [ + "timeType" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_timeType` ON `${TABLE_NAME}` (`timeType`)" + }, + { + "name": "index_transactions_categoryId", + "unique": false, + "columnNames": [ + "categoryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_categoryId` ON `${TABLE_NAME}` (`categoryId`)" + }, + { + "name": "index_transactions_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "trn_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `trnId` TEXT NOT NULL, `batchId` TEXT NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trnId", + "columnName": "trnId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "batchId", + "columnName": "batchId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_trn_links_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_links_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_trn_links_trnId", + "unique": false, + "columnNames": [ + "trnId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_links_trnId` ON `${TABLE_NAME}` (`trnId`)" + }, + { + "name": "index_trn_links_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_links_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "trn_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `trnId` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trnId", + "columnName": "trnId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_trn_metadata_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_trn_metadata_trnId", + "unique": false, + "columnNames": [ + "trnId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_trnId` ON `${TABLE_NAME}` (`trnId`)" + }, + { + "name": "index_trn_metadata_key", + "unique": false, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_key` ON `${TABLE_NAME}` (`key`)" + }, + { + "name": "index_trn_metadata_value", + "unique": false, + "columnNames": [ + "value" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_value` ON `${TABLE_NAME}` (`value`)" + }, + { + "name": "index_trn_metadata_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "attachments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `associatedId` TEXT NOT NULL, `uri` TEXT NOT NULL, `source` INTEGER NOT NULL, `filename` TEXT, `type` INTEGER, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "associatedId", + "columnName": "associatedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_attachments_associatedId", + "unique": false, + "columnNames": [ + "associatedId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_associatedId` ON `${TABLE_NAME}` (`associatedId`)" + }, + { + "name": "index_attachments_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `currency` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `folderId` TEXT, `orderNum` REAL NOT NULL, `excluded` INTEGER NOT NULL, `state` INTEGER NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "excluded", + "columnName": "excluded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_accounts_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_accounts_folderId", + "unique": false, + "columnNames": [ + "folderId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_folderId` ON `${TABLE_NAME}` (`folderId`)" + }, + { + "name": "index_accounts_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_accounts_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_accounts_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "account_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_account_folders_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_folders_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_account_folders_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_folders_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_account_folders_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_folders_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `parentCategoryId` TEXT, `type` INTEGER NOT NULL, `state` INTEGER NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "parentCategoryId", + "columnName": "parentCategoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_categories_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_categories_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_categories_parentCategoryId", + "unique": false, + "columnNames": [ + "parentCategoryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_parentCategoryId` ON `${TABLE_NAME}` (`parentCategoryId`)" + }, + { + "name": "index_categories_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_categories_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_categories_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, `provider` INTEGER, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "baseCurrency", + "currency" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_exchange_rates_baseCurrency", + "unique": false, + "columnNames": [ + "baseCurrency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_baseCurrency` ON `${TABLE_NAME}` (`baseCurrency`)" + }, + { + "name": "index_exchange_rates_currency", + "unique": false, + "columnNames": [ + "currency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_currency` ON `${TABLE_NAME}` (`currency`)" + }, + { + "name": "index_exchange_rates_provider", + "unique": false, + "columnNames": [ + "provider" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_provider` ON `${TABLE_NAME}` (`provider`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates_override", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "baseCurrency", + "currency" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_exchange_rates_override_baseCurrency", + "unique": false, + "columnNames": [ + "baseCurrency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_override_baseCurrency` ON `${TABLE_NAME}` (`baseCurrency`)" + }, + { + "name": "index_exchange_rates_override_currency", + "unique": false, + "columnNames": [ + "currency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_override_currency` ON `${TABLE_NAME}` (`currency`)" + }, + { + "name": "index_exchange_rates_override_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_override_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `color` INTEGER NOT NULL, `name` TEXT NOT NULL, `orderNum` REAL NOT NULL, `state` TEXT NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_tags_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_tags_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_tags_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_tags_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "trn_tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trnId` TEXT NOT NULL, `tagId` TEXT NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`trnId`, `tagId`))", + "fields": [ + { + "fieldPath": "trnId", + "columnName": "trnId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tagId", + "columnName": "tagId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "trnId", + "tagId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_trn_tags_trnId", + "unique": false, + "columnNames": [ + "trnId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_tags_trnId` ON `${TABLE_NAME}` (`trnId`)" + }, + { + "name": "index_trn_tags_tagId", + "unique": false, + "columnNames": [ + "tagId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_tags_tagId` ON `${TABLE_NAME}` (`tagId`)" + }, + { + "name": "index_trn_tags_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_tags_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd959ed54671eafea046647bad1f91748')" + ] + } +} \ No newline at end of file diff --git a/core/room-db-schemas/com.ivy.core.persistence.IvyWalletCoreDb/3.json b/core/room-db-schemas/com.ivy.core.persistence.IvyWalletCoreDb/3.json new file mode 100644 index 0000000..111b689 --- /dev/null +++ b/core/room-db-schemas/com.ivy.core.persistence.IvyWalletCoreDb/3.json @@ -0,0 +1,1190 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "80b9268824400e9cb0bccfe39c9344c3", + "entities": [ + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `type` INTEGER NOT NULL, `amount` REAL NOT NULL, `currency` TEXT NOT NULL, `time` INTEGER NOT NULL, `timeType` INTEGER NOT NULL, `title` TEXT, `description` TEXT, `categoryId` TEXT, `state` TEXT NOT NULL, `purpose` INTEGER, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeType", + "columnName": "timeType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_transactions_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_transactions_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_transactions_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_transactions_time", + "unique": false, + "columnNames": [ + "time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_time` ON `${TABLE_NAME}` (`time`)" + }, + { + "name": "index_transactions_timeType", + "unique": false, + "columnNames": [ + "timeType" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_timeType` ON `${TABLE_NAME}` (`timeType`)" + }, + { + "name": "index_transactions_categoryId", + "unique": false, + "columnNames": [ + "categoryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_categoryId` ON `${TABLE_NAME}` (`categoryId`)" + }, + { + "name": "index_transactions_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "trn_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `trnId` TEXT NOT NULL, `batchId` TEXT NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trnId", + "columnName": "trnId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "batchId", + "columnName": "batchId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_trn_links_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_links_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_trn_links_trnId", + "unique": false, + "columnNames": [ + "trnId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_links_trnId` ON `${TABLE_NAME}` (`trnId`)" + }, + { + "name": "index_trn_links_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_links_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "trn_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `trnId` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trnId", + "columnName": "trnId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_trn_metadata_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_trn_metadata_trnId", + "unique": false, + "columnNames": [ + "trnId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_trnId` ON `${TABLE_NAME}` (`trnId`)" + }, + { + "name": "index_trn_metadata_key", + "unique": false, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_key` ON `${TABLE_NAME}` (`key`)" + }, + { + "name": "index_trn_metadata_value", + "unique": false, + "columnNames": [ + "value" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_value` ON `${TABLE_NAME}` (`value`)" + }, + { + "name": "index_trn_metadata_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "attachments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `associatedId` TEXT NOT NULL, `uri` TEXT NOT NULL, `source` INTEGER NOT NULL, `filename` TEXT, `type` INTEGER, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "associatedId", + "columnName": "associatedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_attachments_associatedId", + "unique": false, + "columnNames": [ + "associatedId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_associatedId` ON `${TABLE_NAME}` (`associatedId`)" + }, + { + "name": "index_attachments_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `currency` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `folderId` TEXT, `orderNum` REAL NOT NULL, `excluded` INTEGER NOT NULL, `state` INTEGER NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "excluded", + "columnName": "excluded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_accounts_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_accounts_folderId", + "unique": false, + "columnNames": [ + "folderId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_folderId` ON `${TABLE_NAME}` (`folderId`)" + }, + { + "name": "index_accounts_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_accounts_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_accounts_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "account_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_account_folders_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_folders_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_account_folders_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_folders_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_account_folders_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_folders_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `parentCategoryId` TEXT, `type` INTEGER NOT NULL, `state` INTEGER NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "parentCategoryId", + "columnName": "parentCategoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_categories_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_categories_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_categories_parentCategoryId", + "unique": false, + "columnNames": [ + "parentCategoryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_parentCategoryId` ON `${TABLE_NAME}` (`parentCategoryId`)" + }, + { + "name": "index_categories_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_categories_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_categories_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, `provider` INTEGER, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "baseCurrency", + "currency" + ] + }, + "indices": [ + { + "name": "index_exchange_rates_baseCurrency", + "unique": false, + "columnNames": [ + "baseCurrency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_baseCurrency` ON `${TABLE_NAME}` (`baseCurrency`)" + }, + { + "name": "index_exchange_rates_currency", + "unique": false, + "columnNames": [ + "currency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_currency` ON `${TABLE_NAME}` (`currency`)" + }, + { + "name": "index_exchange_rates_provider", + "unique": false, + "columnNames": [ + "provider" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_provider` ON `${TABLE_NAME}` (`provider`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates_override", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "baseCurrency", + "currency" + ] + }, + "indices": [ + { + "name": "index_exchange_rates_override_baseCurrency", + "unique": false, + "columnNames": [ + "baseCurrency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_override_baseCurrency` ON `${TABLE_NAME}` (`baseCurrency`)" + }, + { + "name": "index_exchange_rates_override_currency", + "unique": false, + "columnNames": [ + "currency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_override_currency` ON `${TABLE_NAME}` (`currency`)" + }, + { + "name": "index_exchange_rates_override_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_override_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `color` INTEGER NOT NULL, `name` TEXT NOT NULL, `orderNum` REAL NOT NULL, `state` TEXT NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tags_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_tags_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_tags_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_tags_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "trn_tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trnId` TEXT NOT NULL, `tagId` TEXT NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`trnId`, `tagId`))", + "fields": [ + { + "fieldPath": "trnId", + "columnName": "trnId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tagId", + "columnName": "tagId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "trnId", + "tagId" + ] + }, + "indices": [ + { + "name": "index_trn_tags_trnId", + "unique": false, + "columnNames": [ + "trnId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_tags_trnId` ON `${TABLE_NAME}` (`trnId`)" + }, + { + "name": "index_trn_tags_tagId", + "unique": false, + "columnNames": [ + "tagId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_tags_tagId` ON `${TABLE_NAME}` (`tagId`)" + }, + { + "name": "index_trn_tags_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_tags_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `currency` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `folderId` TEXT, `orderNum` REAL NOT NULL, `excluded` INTEGER NOT NULL, `state` INTEGER NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "excluded", + "columnName": "excluded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_accounts_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_accounts_folderId", + "unique": false, + "columnNames": [ + "folderId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_folderId` ON `${TABLE_NAME}` (`folderId`)" + }, + { + "name": "index_accounts_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_accounts_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_accounts_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '80b9268824400e9cb0bccfe39c9344c3')" + ] + } +} \ No newline at end of file diff --git a/core/room-db-schemas/com.ivy.core.persistence.IvyWalletCoreDb/4.json b/core/room-db-schemas/com.ivy.core.persistence.IvyWalletCoreDb/4.json new file mode 100644 index 0000000..6f2117d --- /dev/null +++ b/core/room-db-schemas/com.ivy.core.persistence.IvyWalletCoreDb/4.json @@ -0,0 +1,1124 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "6572e2ab580b0fe4f08175d969897dd4", + "entities": [ + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `type` INTEGER NOT NULL, `amount` REAL NOT NULL, `currency` TEXT NOT NULL, `time` INTEGER NOT NULL, `timeType` INTEGER NOT NULL, `title` TEXT, `description` TEXT, `categoryId` TEXT, `state` TEXT NOT NULL, `purpose` INTEGER, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeType", + "columnName": "timeType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_transactions_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_transactions_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_transactions_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_transactions_time", + "unique": false, + "columnNames": [ + "time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_time` ON `${TABLE_NAME}` (`time`)" + }, + { + "name": "index_transactions_timeType", + "unique": false, + "columnNames": [ + "timeType" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_timeType` ON `${TABLE_NAME}` (`timeType`)" + }, + { + "name": "index_transactions_categoryId", + "unique": false, + "columnNames": [ + "categoryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_categoryId` ON `${TABLE_NAME}` (`categoryId`)" + }, + { + "name": "index_transactions_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "trn_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `trnId` TEXT NOT NULL, `batchId` TEXT NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trnId", + "columnName": "trnId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "batchId", + "columnName": "batchId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_trn_links_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_links_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_trn_links_trnId", + "unique": false, + "columnNames": [ + "trnId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_links_trnId` ON `${TABLE_NAME}` (`trnId`)" + }, + { + "name": "index_trn_links_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_links_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "trn_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `trnId` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trnId", + "columnName": "trnId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_trn_metadata_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_trn_metadata_trnId", + "unique": false, + "columnNames": [ + "trnId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_trnId` ON `${TABLE_NAME}` (`trnId`)" + }, + { + "name": "index_trn_metadata_key", + "unique": false, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_key` ON `${TABLE_NAME}` (`key`)" + }, + { + "name": "index_trn_metadata_value", + "unique": false, + "columnNames": [ + "value" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_value` ON `${TABLE_NAME}` (`value`)" + }, + { + "name": "index_trn_metadata_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "attachments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `associatedId` TEXT NOT NULL, `uri` TEXT NOT NULL, `source` INTEGER NOT NULL, `filename` TEXT, `type` INTEGER, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "associatedId", + "columnName": "associatedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_attachments_associatedId", + "unique": false, + "columnNames": [ + "associatedId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_associatedId` ON `${TABLE_NAME}` (`associatedId`)" + }, + { + "name": "index_attachments_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `currency` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `folderId` TEXT, `orderNum` REAL NOT NULL, `excluded` INTEGER NOT NULL, `state` INTEGER NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "excluded", + "columnName": "excluded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_accounts_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_accounts_folderId", + "unique": false, + "columnNames": [ + "folderId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_folderId` ON `${TABLE_NAME}` (`folderId`)" + }, + { + "name": "index_accounts_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_accounts_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_accounts_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "account_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_account_folders_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_folders_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_account_folders_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_folders_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_account_folders_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_folders_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `parentCategoryId` TEXT, `type` INTEGER NOT NULL, `state` INTEGER NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "parentCategoryId", + "columnName": "parentCategoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_categories_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_categories_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_categories_parentCategoryId", + "unique": false, + "columnNames": [ + "parentCategoryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_parentCategoryId` ON `${TABLE_NAME}` (`parentCategoryId`)" + }, + { + "name": "index_categories_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_categories_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_categories_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, `provider` INTEGER, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "baseCurrency", + "currency" + ] + }, + "indices": [ + { + "name": "index_exchange_rates_baseCurrency", + "unique": false, + "columnNames": [ + "baseCurrency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_baseCurrency` ON `${TABLE_NAME}` (`baseCurrency`)" + }, + { + "name": "index_exchange_rates_currency", + "unique": false, + "columnNames": [ + "currency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_currency` ON `${TABLE_NAME}` (`currency`)" + }, + { + "name": "index_exchange_rates_provider", + "unique": false, + "columnNames": [ + "provider" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_provider` ON `${TABLE_NAME}` (`provider`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates_override", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "baseCurrency", + "currency" + ] + }, + "indices": [ + { + "name": "index_exchange_rates_override_baseCurrency", + "unique": false, + "columnNames": [ + "baseCurrency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_override_baseCurrency` ON `${TABLE_NAME}` (`baseCurrency`)" + }, + { + "name": "index_exchange_rates_override_currency", + "unique": false, + "columnNames": [ + "currency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_override_currency` ON `${TABLE_NAME}` (`currency`)" + }, + { + "name": "index_exchange_rates_override_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_override_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `color` INTEGER NOT NULL, `name` TEXT NOT NULL, `orderNum` REAL NOT NULL, `state` TEXT NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tags_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_tags_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_tags_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_tags_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "trn_tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trnId` TEXT NOT NULL, `tagId` TEXT NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`trnId`, `tagId`))", + "fields": [ + { + "fieldPath": "trnId", + "columnName": "trnId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tagId", + "columnName": "tagId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "trnId", + "tagId" + ] + }, + "indices": [ + { + "name": "index_trn_tags_trnId", + "unique": false, + "columnNames": [ + "trnId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_tags_trnId` ON `${TABLE_NAME}` (`trnId`)" + }, + { + "name": "index_trn_tags_tagId", + "unique": false, + "columnNames": [ + "tagId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_tags_tagId` ON `${TABLE_NAME}` (`tagId`)" + }, + { + "name": "index_trn_tags_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_tags_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "account_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `incomesJson` TEXT NOT NULL, `expensesJson` TEXT NOT NULL, `incomesCount` INTEGER NOT NULL, `expensesCount` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "incomesJson", + "columnName": "incomesJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expensesJson", + "columnName": "expensesJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "incomesCount", + "columnName": "incomesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expensesCount", + "columnName": "expensesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [ + { + "name": "index_account_cache_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_cache_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6572e2ab580b0fe4f08175d969897dd4')" + ] + } +} \ No newline at end of file diff --git a/core/room-db-schemas/com.ivy.core.persistence.IvyWalletCoreDb/5.json b/core/room-db-schemas/com.ivy.core.persistence.IvyWalletCoreDb/5.json new file mode 100644 index 0000000..f223f59 --- /dev/null +++ b/core/room-db-schemas/com.ivy.core.persistence.IvyWalletCoreDb/5.json @@ -0,0 +1,1129 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "5f9317854f5954dab0c2ff023d41b88f", + "entities": [ + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `type` INTEGER NOT NULL, `amount` REAL NOT NULL, `currency` TEXT NOT NULL, `time` INTEGER NOT NULL, `timeType` INTEGER NOT NULL, `title` TEXT, `description` TEXT, `categoryId` TEXT, `state` TEXT NOT NULL, `purpose` INTEGER, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeType", + "columnName": "timeType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_transactions_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_transactions_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_transactions_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_transactions_time", + "unique": false, + "columnNames": [ + "time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_time` ON `${TABLE_NAME}` (`time`)" + }, + { + "name": "index_transactions_timeType", + "unique": false, + "columnNames": [ + "timeType" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_timeType` ON `${TABLE_NAME}` (`timeType`)" + }, + { + "name": "index_transactions_categoryId", + "unique": false, + "columnNames": [ + "categoryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_categoryId` ON `${TABLE_NAME}` (`categoryId`)" + }, + { + "name": "index_transactions_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "trn_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `trnId` TEXT NOT NULL, `batchId` TEXT NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trnId", + "columnName": "trnId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "batchId", + "columnName": "batchId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_trn_links_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_links_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_trn_links_trnId", + "unique": false, + "columnNames": [ + "trnId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_links_trnId` ON `${TABLE_NAME}` (`trnId`)" + }, + { + "name": "index_trn_links_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_links_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "trn_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `trnId` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trnId", + "columnName": "trnId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_trn_metadata_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_trn_metadata_trnId", + "unique": false, + "columnNames": [ + "trnId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_trnId` ON `${TABLE_NAME}` (`trnId`)" + }, + { + "name": "index_trn_metadata_key", + "unique": false, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_key` ON `${TABLE_NAME}` (`key`)" + }, + { + "name": "index_trn_metadata_value", + "unique": false, + "columnNames": [ + "value" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_value` ON `${TABLE_NAME}` (`value`)" + }, + { + "name": "index_trn_metadata_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "attachments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `associatedId` TEXT NOT NULL, `uri` TEXT NOT NULL, `source` INTEGER NOT NULL, `filename` TEXT, `type` INTEGER, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "associatedId", + "columnName": "associatedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_attachments_associatedId", + "unique": false, + "columnNames": [ + "associatedId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_associatedId` ON `${TABLE_NAME}` (`associatedId`)" + }, + { + "name": "index_attachments_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `currency` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `folderId` TEXT, `orderNum` REAL NOT NULL, `excluded` INTEGER NOT NULL, `state` INTEGER NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "excluded", + "columnName": "excluded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_accounts_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_accounts_folderId", + "unique": false, + "columnNames": [ + "folderId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_folderId` ON `${TABLE_NAME}` (`folderId`)" + }, + { + "name": "index_accounts_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_accounts_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_accounts_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "account_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_account_folders_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_folders_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_account_folders_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_folders_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_account_folders_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_folders_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `parentCategoryId` TEXT, `type` INTEGER NOT NULL, `state` INTEGER NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "parentCategoryId", + "columnName": "parentCategoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_categories_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_categories_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_categories_parentCategoryId", + "unique": false, + "columnNames": [ + "parentCategoryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_parentCategoryId` ON `${TABLE_NAME}` (`parentCategoryId`)" + }, + { + "name": "index_categories_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_categories_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_categories_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, `provider` INTEGER, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "baseCurrency", + "currency" + ] + }, + "indices": [ + { + "name": "index_exchange_rates_baseCurrency", + "unique": false, + "columnNames": [ + "baseCurrency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_baseCurrency` ON `${TABLE_NAME}` (`baseCurrency`)" + }, + { + "name": "index_exchange_rates_currency", + "unique": false, + "columnNames": [ + "currency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_currency` ON `${TABLE_NAME}` (`currency`)" + }, + { + "name": "index_exchange_rates_provider", + "unique": false, + "columnNames": [ + "provider" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_provider` ON `${TABLE_NAME}` (`provider`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates_override", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "baseCurrency", + "currency" + ] + }, + "indices": [ + { + "name": "index_exchange_rates_override_baseCurrency", + "unique": false, + "columnNames": [ + "baseCurrency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_override_baseCurrency` ON `${TABLE_NAME}` (`baseCurrency`)" + }, + { + "name": "index_exchange_rates_override_currency", + "unique": false, + "columnNames": [ + "currency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_override_currency` ON `${TABLE_NAME}` (`currency`)" + }, + { + "name": "index_exchange_rates_override_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_override_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `color` INTEGER NOT NULL, `name` TEXT NOT NULL, `orderNum` REAL NOT NULL, `state` TEXT NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tags_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_tags_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_tags_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_tags_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "trn_tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trnId` TEXT NOT NULL, `tagId` TEXT NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`trnId`, `tagId`))", + "fields": [ + { + "fieldPath": "trnId", + "columnName": "trnId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tagId", + "columnName": "tagId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "trnId", + "tagId" + ] + }, + "indices": [ + { + "name": "index_trn_tags_trnId", + "unique": false, + "columnNames": [ + "trnId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_tags_trnId` ON `${TABLE_NAME}` (`trnId`)" + }, + { + "name": "index_trn_tags_tagId", + "unique": false, + "columnNames": [ + "tagId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_tags_tagId` ON `${TABLE_NAME}` (`tagId`)" + }, + { + "name": "index_trn_tags_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_tags_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "account_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `incomesJson` TEXT NOT NULL, `expensesJson` TEXT NOT NULL, `incomesCount` INTEGER NOT NULL, `expensesCount` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "incomesJson", + "columnName": "incomesJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expensesJson", + "columnName": "expensesJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "incomesCount", + "columnName": "incomesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expensesCount", + "columnName": "expensesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [ + { + "name": "index_account_cache_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_cache_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "CalcHistoryTrnView", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT amount, currency, type, time,transactions.id, accountId, categoryId, title, description, timeType,purpose, trn_links.batchId FROM transactions LEFT JOIN trn_links ON trn_links.trnId = transactions.id WHERE transactions.state != 2 AND transactions.sync != 3" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5f9317854f5954dab0c2ff023d41b88f')" + ] + } +} \ No newline at end of file diff --git a/core/room-db-schemas/com.ivy.core.persistence.IvyWalletCoreDb/6.json b/core/room-db-schemas/com.ivy.core.persistence.IvyWalletCoreDb/6.json new file mode 100644 index 0000000..169db28 --- /dev/null +++ b/core/room-db-schemas/com.ivy.core.persistence.IvyWalletCoreDb/6.json @@ -0,0 +1,1129 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "5f9317854f5954dab0c2ff023d41b88f", + "entities": [ + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `type` INTEGER NOT NULL, `amount` REAL NOT NULL, `currency` TEXT NOT NULL, `time` INTEGER NOT NULL, `timeType` INTEGER NOT NULL, `title` TEXT, `description` TEXT, `categoryId` TEXT, `state` TEXT NOT NULL, `purpose` INTEGER, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeType", + "columnName": "timeType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_transactions_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_transactions_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_transactions_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_transactions_time", + "unique": false, + "columnNames": [ + "time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_time` ON `${TABLE_NAME}` (`time`)" + }, + { + "name": "index_transactions_timeType", + "unique": false, + "columnNames": [ + "timeType" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_timeType` ON `${TABLE_NAME}` (`timeType`)" + }, + { + "name": "index_transactions_categoryId", + "unique": false, + "columnNames": [ + "categoryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_categoryId` ON `${TABLE_NAME}` (`categoryId`)" + }, + { + "name": "index_transactions_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "trn_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `trnId` TEXT NOT NULL, `batchId` TEXT NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trnId", + "columnName": "trnId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "batchId", + "columnName": "batchId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_trn_links_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_links_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_trn_links_trnId", + "unique": false, + "columnNames": [ + "trnId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_links_trnId` ON `${TABLE_NAME}` (`trnId`)" + }, + { + "name": "index_trn_links_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_links_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "trn_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `trnId` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trnId", + "columnName": "trnId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_trn_metadata_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_trn_metadata_trnId", + "unique": false, + "columnNames": [ + "trnId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_trnId` ON `${TABLE_NAME}` (`trnId`)" + }, + { + "name": "index_trn_metadata_key", + "unique": false, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_key` ON `${TABLE_NAME}` (`key`)" + }, + { + "name": "index_trn_metadata_value", + "unique": false, + "columnNames": [ + "value" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_value` ON `${TABLE_NAME}` (`value`)" + }, + { + "name": "index_trn_metadata_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "attachments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `associatedId` TEXT NOT NULL, `uri` TEXT NOT NULL, `source` INTEGER NOT NULL, `filename` TEXT, `type` INTEGER, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "associatedId", + "columnName": "associatedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_attachments_associatedId", + "unique": false, + "columnNames": [ + "associatedId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_associatedId` ON `${TABLE_NAME}` (`associatedId`)" + }, + { + "name": "index_attachments_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `currency` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `folderId` TEXT, `orderNum` REAL NOT NULL, `excluded` INTEGER NOT NULL, `state` INTEGER NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "excluded", + "columnName": "excluded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_accounts_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_accounts_folderId", + "unique": false, + "columnNames": [ + "folderId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_folderId` ON `${TABLE_NAME}` (`folderId`)" + }, + { + "name": "index_accounts_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_accounts_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_accounts_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "account_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_account_folders_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_folders_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_account_folders_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_folders_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_account_folders_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_folders_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `parentCategoryId` TEXT, `type` INTEGER NOT NULL, `state` INTEGER NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "parentCategoryId", + "columnName": "parentCategoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_categories_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_categories_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_categories_parentCategoryId", + "unique": false, + "columnNames": [ + "parentCategoryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_parentCategoryId` ON `${TABLE_NAME}` (`parentCategoryId`)" + }, + { + "name": "index_categories_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_categories_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_categories_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, `provider` INTEGER, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "baseCurrency", + "currency" + ] + }, + "indices": [ + { + "name": "index_exchange_rates_baseCurrency", + "unique": false, + "columnNames": [ + "baseCurrency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_baseCurrency` ON `${TABLE_NAME}` (`baseCurrency`)" + }, + { + "name": "index_exchange_rates_currency", + "unique": false, + "columnNames": [ + "currency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_currency` ON `${TABLE_NAME}` (`currency`)" + }, + { + "name": "index_exchange_rates_provider", + "unique": false, + "columnNames": [ + "provider" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_provider` ON `${TABLE_NAME}` (`provider`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates_override", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "baseCurrency", + "currency" + ] + }, + "indices": [ + { + "name": "index_exchange_rates_override_baseCurrency", + "unique": false, + "columnNames": [ + "baseCurrency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_override_baseCurrency` ON `${TABLE_NAME}` (`baseCurrency`)" + }, + { + "name": "index_exchange_rates_override_currency", + "unique": false, + "columnNames": [ + "currency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_override_currency` ON `${TABLE_NAME}` (`currency`)" + }, + { + "name": "index_exchange_rates_override_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_override_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `color` INTEGER NOT NULL, `name` TEXT NOT NULL, `orderNum` REAL NOT NULL, `state` TEXT NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tags_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_tags_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_tags_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_tags_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "trn_tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trnId` TEXT NOT NULL, `tagId` TEXT NOT NULL, `sync` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`trnId`, `tagId`))", + "fields": [ + { + "fieldPath": "trnId", + "columnName": "trnId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tagId", + "columnName": "tagId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "trnId", + "tagId" + ] + }, + "indices": [ + { + "name": "index_trn_tags_trnId", + "unique": false, + "columnNames": [ + "trnId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_tags_trnId` ON `${TABLE_NAME}` (`trnId`)" + }, + { + "name": "index_trn_tags_tagId", + "unique": false, + "columnNames": [ + "tagId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_tags_tagId` ON `${TABLE_NAME}` (`tagId`)" + }, + { + "name": "index_trn_tags_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_tags_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "account_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `incomesJson` TEXT NOT NULL, `expensesJson` TEXT NOT NULL, `incomesCount` INTEGER NOT NULL, `expensesCount` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "incomesJson", + "columnName": "incomesJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expensesJson", + "columnName": "expensesJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "incomesCount", + "columnName": "incomesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expensesCount", + "columnName": "expensesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [ + { + "name": "index_account_cache_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_cache_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "CalcHistoryTrnView", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT amount, currency, type, time,transactions.id, accountId, categoryId, title, description, timeType,purpose, trn_links.batchId FROM transactions LEFT JOIN trn_links ON trn_links.trnId = transactions.id WHERE transactions.state != 2 AND transactions.sync != 3" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5f9317854f5954dab0c2ff023d41b88f')" + ] + } +} \ No newline at end of file diff --git a/core/room-db-schemas/com.ivy.core.persistence.IvyWalletDb/1.json b/core/room-db-schemas/com.ivy.core.persistence.IvyWalletDb/1.json new file mode 100644 index 0000000..335beca --- /dev/null +++ b/core/room-db-schemas/com.ivy.core.persistence.IvyWalletDb/1.json @@ -0,0 +1,1004 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "57157e79e5344cd9fcd3177d22c70523", + "entities": [ + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `type` INTEGER NOT NULL, `amount` REAL NOT NULL, `currency` TEXT NOT NULL, `time` INTEGER NOT NULL, `timeType` INTEGER NOT NULL, `title` TEXT, `description` TEXT, `categoryId` TEXT, `state` TEXT NOT NULL, `purpose` INTEGER, `sync` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeType", + "columnName": "timeType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_transactions_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_transactions_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_transactions_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_transactions_time", + "unique": false, + "columnNames": [ + "time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_time` ON `${TABLE_NAME}` (`time`)" + }, + { + "name": "index_transactions_timeType", + "unique": false, + "columnNames": [ + "timeType" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_timeType` ON `${TABLE_NAME}` (`timeType`)" + }, + { + "name": "index_transactions_categoryId", + "unique": false, + "columnNames": [ + "categoryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_categoryId` ON `${TABLE_NAME}` (`categoryId`)" + }, + { + "name": "index_transactions_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "trn_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `trnId` TEXT NOT NULL, `batchId` TEXT NOT NULL, `sync` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trnId", + "columnName": "trnId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "batchId", + "columnName": "batchId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_trn_links_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_links_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_trn_links_trnId", + "unique": false, + "columnNames": [ + "trnId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_links_trnId` ON `${TABLE_NAME}` (`trnId`)" + }, + { + "name": "index_trn_links_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_links_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "trn_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `trnId` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, `sync` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trnId", + "columnName": "trnId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_trn_metadata_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_trn_metadata_trnId", + "unique": false, + "columnNames": [ + "trnId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_trnId` ON `${TABLE_NAME}` (`trnId`)" + }, + { + "name": "index_trn_metadata_key", + "unique": false, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_key` ON `${TABLE_NAME}` (`key`)" + }, + { + "name": "index_trn_metadata_value", + "unique": false, + "columnNames": [ + "value" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_value` ON `${TABLE_NAME}` (`value`)" + }, + { + "name": "index_trn_metadata_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_metadata_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "attachments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `associatedId` TEXT NOT NULL, `uri` TEXT NOT NULL, `source` INTEGER NOT NULL, `filename` TEXT, `type` INTEGER, `sync` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "associatedId", + "columnName": "associatedId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_attachments_associatedId", + "unique": false, + "columnNames": [ + "associatedId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_associatedId` ON `${TABLE_NAME}` (`associatedId`)" + }, + { + "name": "index_attachments_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `currency` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `folderId` TEXT, `orderNum` REAL NOT NULL, `excluded` INTEGER NOT NULL, `state` INTEGER NOT NULL, `sync` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "excluded", + "columnName": "excluded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_accounts_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_accounts_folderId", + "unique": false, + "columnNames": [ + "folderId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_folderId` ON `${TABLE_NAME}` (`folderId`)" + }, + { + "name": "index_accounts_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_accounts_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_accounts_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "account_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `sync` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_account_folders_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_folders_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_account_folders_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_folders_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_account_folders_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_account_folders_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `icon` TEXT, `orderNum` REAL NOT NULL, `parentCategoryId` TEXT, `type` INTEGER NOT NULL, `state` INTEGER NOT NULL, `sync` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "parentCategoryId", + "columnName": "parentCategoryId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_categories_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_categories_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_categories_parentCategoryId", + "unique": false, + "columnNames": [ + "parentCategoryId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_parentCategoryId` ON `${TABLE_NAME}` (`parentCategoryId`)" + }, + { + "name": "index_categories_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_type` ON `${TABLE_NAME}` (`type`)" + }, + { + "name": "index_categories_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_categories_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_categories_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, `provider` INTEGER, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "baseCurrency", + "currency" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_exchange_rates_baseCurrency", + "unique": false, + "columnNames": [ + "baseCurrency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_baseCurrency` ON `${TABLE_NAME}` (`baseCurrency`)" + }, + { + "name": "index_exchange_rates_currency", + "unique": false, + "columnNames": [ + "currency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_currency` ON `${TABLE_NAME}` (`currency`)" + }, + { + "name": "index_exchange_rates_provider", + "unique": false, + "columnNames": [ + "provider" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_provider` ON `${TABLE_NAME}` (`provider`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "exchange_rates_override", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseCurrency` TEXT NOT NULL, `currency` TEXT NOT NULL, `rate` REAL NOT NULL, `sync` INTEGER NOT NULL, PRIMARY KEY(`baseCurrency`, `currency`))", + "fields": [ + { + "fieldPath": "baseCurrency", + "columnName": "baseCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currency", + "columnName": "currency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "baseCurrency", + "currency" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_exchange_rates_override_baseCurrency", + "unique": false, + "columnNames": [ + "baseCurrency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_override_baseCurrency` ON `${TABLE_NAME}` (`baseCurrency`)" + }, + { + "name": "index_exchange_rates_override_currency", + "unique": false, + "columnNames": [ + "currency" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_override_currency` ON `${TABLE_NAME}` (`currency`)" + }, + { + "name": "index_exchange_rates_override_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exchange_rates_override_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `color` INTEGER NOT NULL, `name` TEXT NOT NULL, `orderNum` REAL NOT NULL, `state` TEXT NOT NULL, `sync` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderNum", + "columnName": "orderNum", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_tags_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_tags_orderNum", + "unique": false, + "columnNames": [ + "orderNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_orderNum` ON `${TABLE_NAME}` (`orderNum`)" + }, + { + "name": "index_tags_state", + "unique": false, + "columnNames": [ + "state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_state` ON `${TABLE_NAME}` (`state`)" + }, + { + "name": "index_tags_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tags_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "trn_tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trnId` TEXT NOT NULL, `tagId` TEXT NOT NULL, `sync` INTEGER NOT NULL, PRIMARY KEY(`trnId`, `tagId`))", + "fields": [ + { + "fieldPath": "trnId", + "columnName": "trnId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tagId", + "columnName": "tagId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "trnId", + "tagId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_trn_tags_trnId", + "unique": false, + "columnNames": [ + "trnId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_tags_trnId` ON `${TABLE_NAME}` (`trnId`)" + }, + { + "name": "index_trn_tags_tagId", + "unique": false, + "columnNames": [ + "tagId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_tags_tagId` ON `${TABLE_NAME}` (`tagId`)" + }, + { + "name": "index_trn_tags_sync", + "unique": false, + "columnNames": [ + "sync" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_trn_tags_sync` ON `${TABLE_NAME}` (`sync`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '57157e79e5344cd9fcd3177d22c70523')" + ] + } +} \ No newline at end of file diff --git a/core/ui/.gitignore b/core/ui/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core/ui/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/ui/README.md b/core/ui/README.md new file mode 100644 index 0000000..f778605 --- /dev/null +++ b/core/ui/README.md @@ -0,0 +1,5 @@ +# [Core] UI + +Exports core UI components and domain UI logic that requires Jetpack Compose dependency. + +Provides built-in CRUD modals for almost all `:core:data-model` types and UI for common UI/UX patterns in Ivy Wallet. \ No newline at end of file diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts new file mode 100644 index 0000000..04976dc --- /dev/null +++ b/core/ui/build.gradle.kts @@ -0,0 +1,22 @@ +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.RoomDB +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":design-system")) + implementation(project(":core:domain")) + implementation(project(":core:persistence")) + implementation(project(":navigation")) + implementation(project(":math")) + RoomDB(api = false) + Testing() +} \ No newline at end of file diff --git a/core/ui/src/main/AndroidManifest.xml b/core/ui/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c8cb087 --- /dev/null +++ b/core/ui/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/GlobalProvider.kt b/core/ui/src/main/java/com/ivy/core/ui/GlobalProvider.kt new file mode 100644 index 0000000..657900e --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/GlobalProvider.kt @@ -0,0 +1,7 @@ +package com.ivy.core.ui + +import android.content.Context + +object GlobalProvider { + lateinit var appContext: Context +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/IvyComposeApp.kt b/core/ui/src/main/java/com/ivy/core/ui/IvyComposeApp.kt new file mode 100644 index 0000000..b0fe355 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/IvyComposeApp.kt @@ -0,0 +1,48 @@ +package com.ivy.core.ui + +import android.net.Uri +import android.view.View +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import com.ivy.data.file.FileType +import com.ivy.design.util.isInPreview +import java.time.LocalDate +import java.time.LocalTime + +@Composable +fun rootView(): View = LocalView.current + +@Composable +fun rootScreen(): RootScreen = if (!isInPreview()) + LocalContext.current as RootScreen else dummyRootScreen() + +private fun dummyRootScreen(): RootScreen = object : RootScreen { + override fun shareIvyWallet() {} + + override fun openUrlInBrowser(url: String) {} + + override fun openGooglePlayAppPage(appId: String) {} + + override fun reviewIvyWallet(dismissReviewCard: Boolean) {} + + override fun pinWidget(widget: Class) {} + + override fun shareCSVFile(fileUri: Uri) {} + + override fun fileChooser(fileType: FileType, onFileChosen: (Uri) -> Unit) {} + + override fun createFile(fileName: String, onFileCreated: (Uri) -> Unit) {} + + override fun shareZipFile(fileUri: Uri) {} + + override fun datePicker( + minDate: LocalDate?, + maxDate: LocalDate?, + initialDate: LocalDate?, + onDatePicked: (LocalDate) -> Unit + ) { + } + + override fun timePicker(onTimePicked: (LocalTime) -> Unit) {} +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/LazyListUtil.kt b/core/ui/src/main/java/com/ivy/core/ui/LazyListUtil.kt new file mode 100644 index 0000000..f2f053c --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/LazyListUtil.kt @@ -0,0 +1,23 @@ +package com.ivy.core.ui + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer + +fun LazyListScope.lastItemSpacerVertical( + height: Dp = 24.dp, +) { + item(key = "last_item_spacer") { + SpacerVer(height = height) + } +} + +fun LazyListScope.lastItemSpacerHorizontal( + width: Dp = 24.dp, +) { + item(key = "last_item_spacer") { + SpacerHor(width = width) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/RootScreen.kt b/core/ui/src/main/java/com/ivy/core/ui/RootScreen.kt new file mode 100644 index 0000000..c309c77 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/RootScreen.kt @@ -0,0 +1,35 @@ +package com.ivy.core.ui + +import android.net.Uri +import com.ivy.data.file.FileType +import java.time.LocalDate +import java.time.LocalTime + +interface RootScreen { + fun shareIvyWallet() + + fun openUrlInBrowser(url: String) + + fun openGooglePlayAppPage(appId: String) + + fun reviewIvyWallet(dismissReviewCard: Boolean) + + fun pinWidget(widget: Class) + + fun shareCSVFile(fileUri: Uri) + + fun fileChooser(fileType: FileType = FileType.Everything, onFileChosen: (Uri) -> Unit) + + fun createFile(fileName: String, onFileCreated: (Uri) -> Unit) + + fun shareZipFile(fileUri: Uri) + + fun datePicker( + minDate: LocalDate?, + maxDate: LocalDate?, + initialDate: LocalDate?, + onDatePicked: (LocalDate) -> Unit + ) + + fun timePicker(onTimePicked: (LocalTime) -> Unit) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/Toaster.kt b/core/ui/src/main/java/com/ivy/core/ui/Toaster.kt new file mode 100644 index 0000000..2d9f9dc --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/Toaster.kt @@ -0,0 +1,16 @@ +package com.ivy.core.ui + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class Toaster @Inject constructor() { + private val _messagesFlow = MutableSharedFlow() + val messages: Flow = _messagesFlow + + suspend fun show(message: String) { + _messagesFlow.emit(message) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/ViewModelUtil.kt b/core/ui/src/main/java/com/ivy/core/ui/ViewModelUtil.kt new file mode 100644 index 0000000..0e7c21c --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/ViewModelUtil.kt @@ -0,0 +1,11 @@ +package com.ivy.core.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import com.ivy.core.domain.FlowViewModel + +@Composable +inline fun uiStatePreviewSafe( + viewModel: FlowViewModel?, + preview: () -> UiState +): UiState = viewModel?.uiState?.collectAsState()?.value ?: preview() \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/AccountBadge.kt b/core/ui/src/main/java/com/ivy/core/ui/account/AccountBadge.kt new file mode 100644 index 0000000..18e6ecb --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/AccountBadge.kt @@ -0,0 +1,58 @@ +package com.ivy.core.ui.account + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import com.ivy.core.ui.R +import com.ivy.core.ui.component.BadgeComponent +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.core.ui.data.icon.dummyIconSized +import com.ivy.design.l0_system.color.Black +import com.ivy.design.l0_system.color.Green +import com.ivy.design.util.ComponentPreview + +@Composable +fun AccountBadge( + account: AccountUi, + modifier: Modifier = Modifier, + background: Color = account.color, + onClick: (() -> Unit)? = null +) { + BadgeComponent( + modifier = modifier, + icon = account.icon, + text = account.name, + background = background, + onClick = onClick, + ) +} + +@Preview +@Composable +private fun Preview_Black() { + ComponentPreview { + AccountBadge( + account = dummyAccountUi( + name = "Cash", + icon = dummyIconSized(R.drawable.ic_custom_account_s) + ), + background = Black + ) + } +} + +@Preview +@Composable +private fun Preview_Color() { + ComponentPreview { + AccountBadge( + account = dummyAccountUi( + name = "Cash", + icon = dummyIconSized(R.drawable.ic_custom_account_s), + color = Green, + ) + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/AccountButton.kt b/core/ui/src/main/java/com/ivy/core/ui/account/AccountButton.kt new file mode 100644 index 0000000..ca2368b --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/AccountButton.kt @@ -0,0 +1,79 @@ +package com.ivy.core.ui.account + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.core.ui.data.icon.IconSize +import com.ivy.core.ui.icon.ItemIcon +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.rememberContrast +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.util.ComponentPreview + +@Composable +fun AccountButton( + account: AccountUi, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Row( + modifier = modifier + .clip(UI.shapes.fullyRounded) + .background(account.color, UI.shapes.fullyRounded) + .clickable(onClick = onClick) + .padding(start = 8.dp, end = 16.dp) + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val contrast = rememberContrast(color = account.color) + ItemIcon( + itemIcon = account.icon, + size = IconSize.S, + tint = contrast, + ) + B2( + modifier = Modifier + .padding(start = 4.dp) + .widthIn(min = 0.dp, max = 120.dp), + text = account.name, + color = contrast, + ) + } +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + ComponentPreview { + AccountButton( + account = dummyAccountUi(), + onClick = {}, + ) + } +} + +@Preview +@Composable +private fun Preview_Long() { + ComponentPreview { + AccountButton( + account = dummyAccountUi( + name = "This is a very long account name, which should be on multiple lines", + ), + onClick = {}, + ) + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/BaseAccountModal.kt b/core/ui/src/main/java/com/ivy/core/ui/account/BaseAccountModal.kt new file mode 100644 index 0000000..1bfd017 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/BaseAccountModal.kt @@ -0,0 +1,239 @@ +package com.ivy.core.ui.account + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.R +import com.ivy.core.ui.account.create.components.AccountCurrency +import com.ivy.core.ui.account.create.components.AccountFolderButton +import com.ivy.core.ui.account.create.components.ExcludeAccount +import com.ivy.core.ui.account.create.components.ExcludedAccInfoModal +import com.ivy.core.ui.account.folder.pick.FolderPickerModal +import com.ivy.core.ui.color.ColorButton +import com.ivy.core.ui.color.picker.ColorPickerModal +import com.ivy.core.ui.component.ItemIconNameRow +import com.ivy.core.ui.currency.CurrencyPickerModal +import com.ivy.core.ui.data.account.FolderUi +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.core.ui.data.icon.dummyIconSized +import com.ivy.core.ui.icon.picker.IconPickerModal +import com.ivy.data.CurrencyCode +import com.ivy.data.ItemIconId +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.DividerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Positive +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l2_components.modal.scope.ModalActionsScope +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.util.IvyPreview + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun BoxScope.BaseAccountModal( + modal: IvyModal, + level: Int, + autoFocusNameInput: Boolean, + title: String, + nameInputHint: String, + positiveActionText: String, + secondaryActions: (@Composable ModalActionsScope.() -> Unit)? = null, + icon: ItemIcon, + initialName: String, + color: Color, + currency: CurrencyCode, + folder: FolderUi?, + excluded: Boolean, + contentBelow: (LazyListScope.() -> Unit)? = null, + onIconChange: (ItemIconId) -> Unit, + onNameChange: (String) -> Unit, + onColorChange: (Color) -> Unit, + onCurrencyChange: (CurrencyCode) -> Unit, + onFolderChange: (FolderUi?) -> Unit, + onExcludedChange: (Boolean) -> Unit, + onSaveAccount: (SaveAccountInfo) -> Unit, +) { + val iconPickerModal = rememberIvyModal() + val colorPickerModal = rememberIvyModal() + val currencyPickerModal = rememberIvyModal() + val excludedAccInfoModal = rememberIvyModal() + val chooseFolderModal = rememberIvyModal() + + val keyboardController = LocalSoftwareKeyboardController.current + Modal( + modal = modal, + level = level, + actions = { + secondaryActions?.invoke(this) + Positive( + text = positiveActionText, + feeling = Feeling.Custom(color) + ) { + onSaveAccount( + SaveAccountInfo( + color = color, + excluded = excluded, + folder = folder, + ) + ) + keyboardController?.hide() + modal.hide() + } + } + ) { + LazyColumn(modifier = Modifier.weight(1f)) { + item(key = "modal_title") { + Title(text = title) + SpacerVer(height = 24.dp) + } + item(key = "icon_name_color") { + // Keep in one item because so the title + // won't disappear on scroll + ItemIconNameRow( + icon = icon, + color = color, + initialName = initialName, + nameInputHint = nameInputHint, + autoFocusInput = autoFocusNameInput, + onPickIcon = { + keyboardController?.hide() + iconPickerModal.show() + }, + onNameChange = onNameChange, + ) + SpacerVer(height = 16.dp) + ColorButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + color = color + ) { + keyboardController?.hide() + colorPickerModal.show() + } + SpacerVer(height = 16.dp) + } + item(key = "acc_currency") { + AccountCurrency( + currency = currency, + color = color, + onPickCurrency = { + keyboardController?.hide() + currencyPickerModal.show() + } + ) + SpacerVer(height = 12.dp) + } + item(key = "acc_folder") { + AccountFolderButton( + folder = folder, + color = color, + ) { + keyboardController?.hide() + chooseFolderModal.show() + } + } + item(key = "line_divider") { + SpacerVer(height = 24.dp) + DividerHor() + SpacerVer(height = 12.dp) + } + item(key = "exclude_acc") { + ExcludeAccount( + excluded = excluded, + onMoreInfo = { + keyboardController?.hide() + excludedAccInfoModal.show() + }, + onExcludedChange = onExcludedChange, + ) + } + contentBelow?.invoke(this) + item(key = "last_item_spacer") { + SpacerVer(height = 48.dp) // last spacer + } + } + } + + IconPickerModal( + modal = iconPickerModal, + level = level + 1, + initialIcon = icon, + color = color, + onIconPick = onIconChange, + ) + ColorPickerModal( + modal = colorPickerModal, + level = level + 1, + initialColor = color, + onColorPicked = onColorChange, + ) + CurrencyPickerModal( + modal = currencyPickerModal, + level = level + 1, + initialCurrency = currency, + onCurrencyPick = onCurrencyChange, + ) + ExcludedAccInfoModal( + modal = excludedAccInfoModal, + level = level + 1, + ) + FolderPickerModal( + modal = chooseFolderModal, + level = level + 1, + selected = folder, + onPickFolder = onFolderChange, + ) +} + +data class SaveAccountInfo( + val color: Color, + val excluded: Boolean, + val folder: FolderUi? +) + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + BaseAccountModal( + modal = modal, + level = 1, + autoFocusNameInput = false, + title = stringResource(R.string.edit_account), + nameInputHint = stringResource(R.string.account_name), + positiveActionText = stringResource(R.string.save), + icon = dummyIconSized(R.drawable.ic_custom_account_m), + color = UI.colors.primary, + initialName = "Account", + excluded = false, + folder = null, + currency = "USD", + onNameChange = {}, + onIconChange = {}, + onCurrencyChange = {}, + onSaveAccount = {}, + onColorChange = {}, + onExcludedChange = {}, + onFolderChange = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/adjustbalance/AdjustBalanceEvent.kt b/core/ui/src/main/java/com/ivy/core/ui/account/adjustbalance/AdjustBalanceEvent.kt new file mode 100644 index 0000000..f5e2ed0 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/adjustbalance/AdjustBalanceEvent.kt @@ -0,0 +1,12 @@ +package com.ivy.core.ui.account.adjustbalance + +import com.ivy.core.ui.account.adjustbalance.data.AdjustType +import com.ivy.data.Value + +internal sealed interface AdjustBalanceEvent { + data class Initial(val accountId: String) : AdjustBalanceEvent + + data class AdjustTypeChange(val type: AdjustType) : AdjustBalanceEvent + + data class AdjustBalance(val balance: Value) : AdjustBalanceEvent +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/adjustbalance/AdjustBalanceModal.kt b/core/ui/src/main/java/com/ivy/core/ui/account/adjustbalance/AdjustBalanceModal.kt new file mode 100644 index 0000000..79b731b --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/adjustbalance/AdjustBalanceModal.kt @@ -0,0 +1,179 @@ +package com.ivy.core.ui.account.adjustbalance + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.domain.pure.dummy.dummyValue +import com.ivy.core.ui.account.adjustbalance.data.AdjustType +import com.ivy.core.ui.amount.AmountModal +import com.ivy.core.ui.uiStatePreviewSafe +import com.ivy.data.Value +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.rememberContrast +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l1_buildingBlocks.Caption +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l2_components.modal.scope.ModalScope +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.hiltViewModelPreviewSafe +import com.ivy.design.util.thenWhen + +@Composable +fun BoxScope.AdjustBalanceModal( + modal: IvyModal, + level: Int = 1, + balance: Value, + accountId: String, +) { + val viewModel: AdjustBalanceViewModel? = hiltViewModelPreviewSafe() + val state = uiStatePreviewSafe(viewModel, preview = ::previewState) + + LaunchedEffect(accountId) { + viewModel?.onEvent( + AdjustBalanceEvent.Initial( + accountId = accountId, + ) + ) + } + + val calculatorVisible = remember { mutableStateOf(false) } + + AmountModal( + modal = modal, + level = level, + calculatorVisible = calculatorVisible, + contentAbove = { + Header( + type = state.adjustType, + onAdjustTypeChange = { + viewModel?.onEvent(AdjustBalanceEvent.AdjustTypeChange(it)) + } + ) + }, + initialAmount = balance, + onAmountEnter = { + viewModel?.onEvent( + AdjustBalanceEvent.AdjustBalance( + balance = it, + ) + ) + } + ) +} + +@Composable +private fun ModalScope.Header( + type: AdjustType, + onAdjustTypeChange: (AdjustType) -> Unit, +) { + Title(text = "Adjust balance") + SpacerVer(height = 8.dp) + AdjustType( + type = type, + onAdjustTypeChange = onAdjustTypeChange, + ) + SpacerVer(height = 12.dp) +} + +@Composable +private fun AdjustType( + type: AdjustType, + onAdjustTypeChange: (AdjustType) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AdjustTypeButton( + modifier = Modifier.weight(1f), + title = "Transaction", + desc = "Adjust transaction will be created.", + selected = type == AdjustType.WithTransaction + ) { + onAdjustTypeChange(AdjustType.WithTransaction) + } + SpacerHor(width = 8.dp) + AdjustTypeButton( + modifier = Modifier.weight(1f), + title = "Artificially", + desc = "No transaction will be created.", + selected = type == AdjustType.NoTransaction + ) { + onAdjustTypeChange(AdjustType.NoTransaction) + } + } +} + +@Composable +private fun AdjustTypeButton( + title: String, + desc: String, + selected: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Column( + modifier = modifier + .clip(UI.shapes.squared) + .thenWhen { + when (selected) { + true -> background(UI.colors.primary, UI.shapes.squared) + false -> border(1.dp, UI.colors.primary, UI.shapes.squared) + } + } + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + val textColor = if (selected) + rememberContrast(UI.colors.primary) else UI.colorsInverted.pure + B2( + modifier = Modifier.fillMaxWidth(), + text = title, + color = textColor, + maxLines = 1, + ) + SpacerVer(height = 4.dp) + Caption( + modifier = Modifier.fillMaxWidth(), + text = desc, + color = if (selected) textColor else UI.colors.neutral, + ) + } +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + AdjustBalanceModal( + modal = modal, + balance = dummyValue(0.0), + accountId = "", + ) + } +} + +private fun previewState() = AdjustBalanceState( + adjustType = AdjustType.WithTransaction, +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/adjustbalance/AdjustBalanceState.kt b/core/ui/src/main/java/com/ivy/core/ui/account/adjustbalance/AdjustBalanceState.kt new file mode 100644 index 0000000..2d44e51 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/adjustbalance/AdjustBalanceState.kt @@ -0,0 +1,9 @@ +package com.ivy.core.ui.account.adjustbalance + +import androidx.compose.runtime.Immutable +import com.ivy.core.ui.account.adjustbalance.data.AdjustType + +@Immutable +internal data class AdjustBalanceState( + val adjustType: AdjustType, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/adjustbalance/AdjustBalanceViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/account/adjustbalance/AdjustBalanceViewModel.kt new file mode 100644 index 0000000..d313582 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/adjustbalance/AdjustBalanceViewModel.kt @@ -0,0 +1,102 @@ +package com.ivy.core.ui.account.adjustbalance + +import com.ivy.core.domain.FlowViewModel +import com.ivy.core.domain.action.account.AccountsFlow +import com.ivy.core.domain.action.account.AdjustAccBalanceAct +import com.ivy.core.domain.action.exchange.ExchangeRatesFlow +import com.ivy.core.domain.pure.exchange.exchange +import com.ivy.core.ui.account.adjustbalance.AdjustBalanceViewModel.State +import com.ivy.core.ui.account.adjustbalance.data.AdjustType +import com.ivy.data.account.Account +import com.ivy.data.exchange.ExchangeRates +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@HiltViewModel +internal class AdjustBalanceViewModel @Inject constructor( + exchangeRatesFlow: ExchangeRatesFlow, + private val adjustAccBalanceAct: AdjustAccBalanceAct, + private val accountsFlow: AccountsFlow, +) : FlowViewModel() { + override val initialState: State = State( + ratesData = ExchangeRates( + baseCurrency = "", + rates = emptyMap(), + ), + account = null, + ) + + override val initialUi = AdjustBalanceState( + adjustType = AdjustType.WithTransaction, + ) + + private val accountId = MutableStateFlow("") + private val adjustType = MutableStateFlow(AdjustType.WithTransaction) + + override val stateFlow: Flow = combine( + exchangeRatesFlow(), accountFlow(), + ) { ratesData, account -> + State( + ratesData = ratesData, + account = account, + ) + } + + private fun accountFlow(): Flow = combine( + accountsFlow(), accountId + ) { accounts, accountId -> + accounts.firstOrNull { it.id.toString() == accountId } + } + + override val uiFlow: Flow = adjustType.map { adjustType -> + AdjustBalanceState( + adjustType = adjustType, + ) + } + + // region Event handling + override suspend fun handleEvent(event: AdjustBalanceEvent) = when (event) { + is AdjustBalanceEvent.Initial -> handleInitial(event) + is AdjustBalanceEvent.AdjustBalance -> handleAdjustBalance(event) + is AdjustBalanceEvent.AdjustTypeChange -> handleAdjustTypeChange(event) + } + + private fun handleInitial(event: AdjustBalanceEvent.Initial) { + accountId.value = event.accountId + } + + private suspend fun handleAdjustBalance(event: AdjustBalanceEvent.AdjustBalance) { + val account = state.value.account ?: return + val accountAmount = exchange( + exchangeData = state.value.ratesData, + from = event.balance.currency, + to = account.currency, + amount = event.balance.amount + ).orNull() ?: return + + adjustAccBalanceAct( + AdjustAccBalanceAct.Input( + account = account, + desiredBalance = accountAmount, + hideTransaction = when (adjustType.value) { + AdjustType.WithTransaction -> false + AdjustType.NoTransaction -> true + } + ) + ) + } + + private fun handleAdjustTypeChange(event: AdjustBalanceEvent.AdjustTypeChange) { + adjustType.value = event.type + } + // endregion + + data class State( + val ratesData: ExchangeRates, + val account: Account?, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/adjustbalance/data/AdjustType.kt b/core/ui/src/main/java/com/ivy/core/ui/account/adjustbalance/data/AdjustType.kt new file mode 100644 index 0000000..0ba0f72 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/adjustbalance/data/AdjustType.kt @@ -0,0 +1,8 @@ +package com.ivy.core.ui.account.adjustbalance.data + +import androidx.compose.runtime.Immutable + +@Immutable +enum class AdjustType { + WithTransaction, NoTransaction +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/create/CreateAccountEvent.kt b/core/ui/src/main/java/com/ivy/core/ui/account/create/CreateAccountEvent.kt new file mode 100644 index 0000000..d85b9e7 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/create/CreateAccountEvent.kt @@ -0,0 +1,20 @@ +package com.ivy.core.ui.account.create + +import androidx.compose.ui.graphics.Color +import com.ivy.core.ui.data.account.FolderUi +import com.ivy.data.CurrencyCode +import com.ivy.data.ItemIconId + +internal sealed interface CreateAccountEvent { + data class CreateAccount( + val color: Color, + val excluded: Boolean, + val folder: FolderUi? + ) : CreateAccountEvent + + data class IconChange(val iconId: ItemIconId) : CreateAccountEvent + + data class NameChange(val name: String) : CreateAccountEvent + + data class CurrencyChange(val newCurrency: CurrencyCode) : CreateAccountEvent +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/create/CreateAccountModal.kt b/core/ui/src/main/java/com/ivy/core/ui/account/create/CreateAccountModal.kt new file mode 100644 index 0000000..4339172 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/create/CreateAccountModal.kt @@ -0,0 +1,78 @@ +package com.ivy.core.ui.account.create + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.* +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.ivy.core.ui.R +import com.ivy.core.ui.account.BaseAccountModal +import com.ivy.core.ui.data.account.FolderUi +import com.ivy.core.ui.data.icon.dummyIconSized +import com.ivy.design.l0_system.UI +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.hiltViewModelPreviewSafe + +@Composable +fun BoxScope.CreateAccountModal( + modal: IvyModal, + level: Int = 1 +) { + val viewModel: CreateAccountViewModel? = hiltViewModelPreviewSafe() + val state = viewModel?.uiState?.collectAsState()?.value ?: previewState() + + val primary = UI.colors.primary + var color by remember(primary) { mutableStateOf(primary) } + var excluded by remember { mutableStateOf(false) } + var folder by remember { mutableStateOf(null) } + + val newAccountString = stringResource(R.string.new_account) + BaseAccountModal( + modal = modal, + level = level, + autoFocusNameInput = true, + title = newAccountString, + nameInputHint = newAccountString, + positiveActionText = stringResource(R.string.add_account), + icon = state.icon, + initialName = "", + currency = state.currency, + color = color, + excluded = excluded, + folder = folder, + onNameChange = { viewModel?.onEvent(CreateAccountEvent.NameChange(it)) }, + onIconChange = { viewModel?.onEvent(CreateAccountEvent.IconChange(it)) }, + onCurrencyChange = { viewModel?.onEvent(CreateAccountEvent.CurrencyChange(it)) }, + onFolderChange = { folder = it }, + onExcludedChange = { excluded = it }, + onColorChange = { color = it }, + onSaveAccount = { + viewModel?.onEvent( + CreateAccountEvent.CreateAccount( + color = it.color, + excluded = it.excluded, + folder = it.folder + ) + ) + } + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + CreateAccountModal(modal = modal) + } +} + +private fun previewState() = CreateAccountState( + currency = "USD", + icon = dummyIconSized(R.drawable.ic_custom_account_m) +) +// endregion diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/create/CreateAccountState.kt b/core/ui/src/main/java/com/ivy/core/ui/account/create/CreateAccountState.kt new file mode 100644 index 0000000..2a4f8b1 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/create/CreateAccountState.kt @@ -0,0 +1,11 @@ +package com.ivy.core.ui.account.create + +import androidx.compose.runtime.Immutable +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.data.CurrencyCode + +@Immutable +internal data class CreateAccountState( + val currency: CurrencyCode, + val icon: ItemIcon +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/create/CreateAccountViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/account/create/CreateAccountViewModel.kt new file mode 100644 index 0000000..92d3971 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/create/CreateAccountViewModel.kt @@ -0,0 +1,98 @@ +package com.ivy.core.ui.account.create + +import androidx.compose.ui.graphics.toArgb +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.toUUID +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.action.account.NewAccountTabItemOrderNumAct +import com.ivy.core.domain.action.account.WriteAccountsAct +import com.ivy.core.domain.action.data.Modify +import com.ivy.core.domain.action.settings.basecurrency.BaseCurrencyFlow +import com.ivy.core.ui.R +import com.ivy.core.ui.action.DefaultTo +import com.ivy.core.ui.action.ItemIconAct +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.data.CurrencyCode +import com.ivy.data.ItemIconId +import com.ivy.data.Sync +import com.ivy.data.SyncState +import com.ivy.data.account.Account +import com.ivy.data.account.AccountState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import java.util.* +import javax.inject.Inject + +@HiltViewModel +internal class CreateAccountViewModel @Inject constructor( + private val itemIconAct: ItemIconAct, + private val writeAccountsAct: WriteAccountsAct, + private val newAccountTabItemOrderNumAct: NewAccountTabItemOrderNumAct, + baseCurrencyFlow: BaseCurrencyFlow, + private val timeProvider: TimeProvider, +) : SimpleFlowViewModel() { + override val initialUi = CreateAccountState( + currency = "", + icon = ItemIcon.Sized( + iconS = R.drawable.ic_custom_account_s, + iconM = R.drawable.ic_custom_account_m, + iconL = R.drawable.ic_custom_account_l, + iconId = null + ) + ) + + private var name = "" + private val currency = MutableStateFlow(null) + private val iconId = MutableStateFlow(null) + + override val uiFlow: Flow = combine( + baseCurrencyFlow(), currency, iconId + ) { baseCurrency, currency, iconId -> + CreateAccountState( + currency = currency ?: baseCurrency, + icon = itemIconAct(ItemIconAct.Input(iconId, DefaultTo.Account)) + ) + } + + // region Event Handling + override suspend fun handleEvent(event: CreateAccountEvent) = when (event) { + is CreateAccountEvent.CreateAccount -> createAccount(event) + is CreateAccountEvent.IconChange -> handleIconPick(event) + is CreateAccountEvent.NameChange -> handleNameChange(event) + is CreateAccountEvent.CurrencyChange -> handleCurrencyChange(event) + } + + private suspend fun createAccount(event: CreateAccountEvent.CreateAccount) { + val newAccount = Account( + id = UUID.randomUUID(), + name = name, + currency = uiState.value.currency, + color = event.color.toArgb(), + icon = iconId.value, + excluded = event.excluded, + folderId = event.folder?.id?.toUUID(), + orderNum = newAccountTabItemOrderNumAct(Unit), + state = AccountState.Default, + sync = Sync( + state = SyncState.Syncing, + lastUpdated = timeProvider.timeNow(), + ), + ) + writeAccountsAct(Modify.save(newAccount)) + } + + private fun handleIconPick(event: CreateAccountEvent.IconChange) { + iconId.value = event.iconId + } + + private fun handleNameChange(event: CreateAccountEvent.NameChange) { + name = event.name + } + + private fun handleCurrencyChange(event: CreateAccountEvent.CurrencyChange) { + currency.value = event.newCurrency + } + // endregion +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/create/components/AccountCurrency.kt b/core/ui/src/main/java/com/ivy/core/ui/account/create/components/AccountCurrency.kt new file mode 100644 index 0000000..2d1da23 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/create/components/AccountCurrency.kt @@ -0,0 +1,60 @@ +package com.ivy.core.ui.account.create.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.R +import com.ivy.data.CurrencyCode +import com.ivy.design.l0_system.color.Purple +import com.ivy.design.l1_buildingBlocks.B1 +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview + +@Composable +internal fun ColumnScope.AccountCurrency( + currency: CurrencyCode, + color: Color, + modifier: Modifier = Modifier, + onPickCurrency: () -> Unit +) { + B1( + modifier = Modifier.padding(start = 24.dp), + text = "Account currency" + ) + SpacerVer(height = 8.dp) + IvyButton( + modifier = modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Custom(color), + text = currency, + icon = R.drawable.round_currency_exchange_24, + onClick = onPickCurrency + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + ComponentPreview { + Column { + AccountCurrency( + currency = "BGN", + color = Purple, + onPickCurrency = {} + ) + } + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/create/components/AccountFolder.kt b/core/ui/src/main/java/com/ivy/core/ui/account/create/components/AccountFolder.kt new file mode 100644 index 0000000..cda5935 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/create/components/AccountFolder.kt @@ -0,0 +1,80 @@ +package com.ivy.core.ui.account.create.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.R +import com.ivy.core.ui.account.folder.pick.FolderItem +import com.ivy.core.ui.data.account.FolderUi +import com.ivy.core.ui.data.account.dummyFolderUi +import com.ivy.design.l0_system.color.Purple +import com.ivy.design.l1_buildingBlocks.B1 +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview + +@Composable +internal fun ColumnScope.AccountFolderButton( + folder: FolderUi?, + modifier: Modifier = Modifier, + color: Color, + onClick: () -> Unit +) { + B1( + modifier = Modifier.padding(start = 24.dp), + text = "Folder" + ) + SpacerVer(height = 8.dp) + if (folder != null) { + FolderItem( + folder = folder, + selected = true, + onClick = onClick, + ) + } else { + IvyButton( + modifier = modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Custom(color), + text = "Choose folder", + icon = R.drawable.ic_vue_files_folder, + onClick = onClick + ) + } +} + + +// region Preview +@Preview +@Composable +private fun Preview_None() { + ComponentPreview { + Column { + AccountFolderButton(folder = null, color = Purple) {} + } + } +} + +@Preview +@Composable +private fun Preview_Selected() { + ComponentPreview { + Column { + AccountFolderButton( + folder = dummyFolderUi("Business"), + color = Purple, + onClick = {} + ) + } + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/create/components/ExcludeAccount.kt b/core/ui/src/main/java/com/ivy/core/ui/account/create/components/ExcludeAccount.kt new file mode 100644 index 0000000..84048e1 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/create/components/ExcludeAccount.kt @@ -0,0 +1,105 @@ +package com.ivy.core.ui.account.create.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.Switch +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Body +import com.ivy.design.l2_components.modal.components.Positive +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.MoreInfoButton +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.IvyPreview + +@Composable +internal fun ExcludeAccount( + excluded: Boolean, + modifier: Modifier = Modifier, + onMoreInfo: () -> Unit, + onExcludedChange: (excluded: Boolean) -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(UI.shapes.fullyRounded) + .clickable { onExcludedChange(!excluded) }, + verticalAlignment = Alignment.CenterVertically + ) { + Switch( + enabled = excluded, + enabledColor = UI.colors.red, + onEnabledChange = onExcludedChange + ) + B2( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp, end = 4.dp), + text = "Exclude account", + fontWeight = FontWeight.ExtraBold, + color = UI.colorsInverted.pure, + ) + MoreInfoButton(onClick = onMoreInfo) + } +} + +@Composable +internal fun BoxScope.ExcludedAccInfoModal( + modal: IvyModal, + level: Int = 1, +) { + Modal( + modal = modal, + level = level, + actions = { + Positive(text = "Got it") { + modal.hide() + } + } + ) { + Title(text = "Excluded accounts") + SpacerVer(height = 24.dp) + Body( + text = "Excluded accounts don't count to your balance" + + " that you see on the \"Home\" screen. However, they're calculated" + + " in your expenses and you can still add transactions in them." + ) + SpacerVer(height = 48.dp) + } +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + ComponentPreview { + ExcludeAccount(excluded = false, onMoreInfo = { }, onExcludedChange = {}) + } +} + +@Preview +@Composable +private fun Preview_InfoModal() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + ExcludedAccInfoModal(modal = modal) + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/edit/EditAccountEvent.kt b/core/ui/src/main/java/com/ivy/core/ui/account/edit/EditAccountEvent.kt new file mode 100644 index 0000000..cc950c4 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/edit/EditAccountEvent.kt @@ -0,0 +1,28 @@ +package com.ivy.core.ui.account.edit + +import androidx.compose.ui.graphics.Color +import com.ivy.core.ui.data.account.FolderUi +import com.ivy.data.CurrencyCode +import com.ivy.data.ItemIconId + +internal sealed interface EditAccountEvent { + data class Initial(val accountId: String) : EditAccountEvent + + object EditAccount : EditAccountEvent + + data class IconChange(val iconId: ItemIconId) : EditAccountEvent + + data class NameChange(val name: String) : EditAccountEvent + + data class CurrencyChange(val newCurrency: CurrencyCode) : EditAccountEvent + + data class ColorChange(val color: Color) : EditAccountEvent + + data class FolderChange(val folder: FolderUi?) : EditAccountEvent + + data class ExcludedChange(val excluded: Boolean) : EditAccountEvent + + object Archive : EditAccountEvent + object Unarchive : EditAccountEvent + object Delete : EditAccountEvent +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/edit/EditAccountModal.kt b/core/ui/src/main/java/com/ivy/core/ui/account/edit/EditAccountModal.kt new file mode 100644 index 0000000..0d60573 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/edit/EditAccountModal.kt @@ -0,0 +1,203 @@ +package com.ivy.core.ui.account.edit + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +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 com.ivy.core.domain.pure.dummy.dummyValue +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.dummyValueUi +import com.ivy.core.ui.R +import com.ivy.core.ui.account.BaseAccountModal +import com.ivy.core.ui.account.adjustbalance.AdjustBalanceModal +import com.ivy.core.ui.account.edit.components.DeleteAccountModal +import com.ivy.core.ui.data.icon.dummyIconSized +import com.ivy.core.ui.value.AmountCurrency +import com.ivy.design.l0_system.color.Purple +import com.ivy.design.l1_buildingBlocks.B1 +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ArchiveButton +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.DeleteButton +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.hiltViewModelPreviewSafe + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun BoxScope.EditAccountModal( + modal: IvyModal, + accountId: String, + level: Int = 1, +) { + val viewModel: EditAccountViewModel? = hiltViewModelPreviewSafe() + val state = viewModel?.uiState?.collectAsState()?.value ?: previewState() + + LaunchedEffect(accountId) { + viewModel?.onEvent(EditAccountEvent.Initial(accountId)) + } + + val deleteAccountModal = rememberIvyModal() + val adjustBalanceModal = rememberIvyModal() + + val keyboardController = LocalSoftwareKeyboardController.current + BaseAccountModal( + modal = modal, + level = level, + autoFocusNameInput = false, + title = stringResource(R.string.edit_account), + nameInputHint = stringResource(R.string.account_name), + positiveActionText = stringResource(R.string.save), + secondaryActions = { + ArchiveButton( + archived = state.archived, + color = state.color, + onArchive = { + keyboardController?.hide() + modal.hide() + viewModel?.onEvent(EditAccountEvent.Archive) + }, + onUnarchive = { + keyboardController?.hide() + modal.hide() + viewModel?.onEvent(EditAccountEvent.Unarchive) + } + ) + SpacerHor(width = 8.dp) + DeleteButton { + keyboardController?.hide() + deleteAccountModal.show() + } + SpacerHor(width = 12.dp) + }, + icon = state.icon, + initialName = state.initialName, + currency = state.currency, + color = state.color, + excluded = state.excluded, + folder = state.folder, + contentBelow = { + item(key = "adjust_balance") { + AdjustBalance( + balance = state.balanceUi, + color = state.color + ) { + adjustBalanceModal.show() + } + } + }, + onNameChange = { viewModel?.onEvent(EditAccountEvent.NameChange(it)) }, + onIconChange = { viewModel?.onEvent(EditAccountEvent.IconChange(it)) }, + onCurrencyChange = { viewModel?.onEvent(EditAccountEvent.CurrencyChange(it)) }, + onFolderChange = { viewModel?.onEvent(EditAccountEvent.FolderChange(it)) }, + onExcludedChange = { viewModel?.onEvent(EditAccountEvent.ExcludedChange(it)) }, + onColorChange = { viewModel?.onEvent(EditAccountEvent.ColorChange(it)) }, + onSaveAccount = { viewModel?.onEvent(EditAccountEvent.EditAccount) } + ) + + DeleteAccountModal( + modal = deleteAccountModal, + level = level + 1, + accountName = state.initialName, + archived = state.archived, + onArchive = { + keyboardController?.hide() + modal.hide() + viewModel?.onEvent(EditAccountEvent.Archive) + }, + onDelete = { + keyboardController?.hide() + modal.hide() + viewModel?.onEvent(EditAccountEvent.Delete) + } + ) + AdjustBalanceModal( + modal = adjustBalanceModal, + level = level + 1, + balance = state.balance, + accountId = state.accountId, + ) +} + +// region Adjust balance +@Composable +private fun AdjustBalance( + balance: ValueUi, + color: Color, + onClick: () -> Unit +) { + SpacerVer(height = 24.dp) + B1( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + text = "Account's balance", + textAlign = TextAlign.Center, + color = color, + ) + SpacerVer(height = 8.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + AmountCurrency(balance) + } + SpacerVer(height = 12.dp) + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Custom(color), + text = stringResource(R.string.adjust_balance), + icon = R.drawable.ic_vue_money_coins, + onClick = onClick + ) +} +// endregion + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + EditAccountModal( + modal = modal, + accountId = "" + ) + } +} + +private fun previewState() = EditAccountState( + accountId = "", + currency = "USD", + icon = dummyIconSized(R.drawable.ic_custom_account_m), + initialName = "Account", + folder = null, + excluded = false, + color = Purple, + archived = false, + balance = dummyValue(1_000.0), + balanceUi = dummyValueUi("1,000.00") +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/edit/EditAccountState.kt b/core/ui/src/main/java/com/ivy/core/ui/account/edit/EditAccountState.kt new file mode 100644 index 0000000..7e7b2dd --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/edit/EditAccountState.kt @@ -0,0 +1,23 @@ +package com.ivy.core.ui.account.edit + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.ui.data.account.FolderUi +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.data.CurrencyCode +import com.ivy.data.Value + +@Immutable +internal data class EditAccountState( + val accountId: String, + val currency: CurrencyCode, + val icon: ItemIcon, + val color: Color, + val initialName: String, + val folder: FolderUi?, + val excluded: Boolean, + val archived: Boolean, + val balance: Value, + val balanceUi: ValueUi, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/edit/EditAccountViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/account/edit/EditAccountViewModel.kt new file mode 100644 index 0000000..b1297fd --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/edit/EditAccountViewModel.kt @@ -0,0 +1,241 @@ +package com.ivy.core.ui.account.edit + +import android.annotation.SuppressLint +import android.content.Context +import android.widget.Toast +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.toUUID +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.action.account.AccountByIdAct +import com.ivy.core.domain.action.account.WriteAccountsAct +import com.ivy.core.domain.action.account.folder.AccountFoldersFlow +import com.ivy.core.domain.action.calculate.account.AccBalanceFlow +import com.ivy.core.domain.action.data.AccountListItem +import com.ivy.core.domain.action.data.Modify +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.format +import com.ivy.core.ui.R +import com.ivy.core.ui.action.DefaultTo +import com.ivy.core.ui.action.ItemIconAct +import com.ivy.core.ui.action.mapping.account.MapFolderUiAct +import com.ivy.core.ui.data.account.FolderUi +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.data.* +import com.ivy.data.account.Account +import com.ivy.data.account.AccountState +import com.ivy.design.l0_system.color.Purple +import com.ivy.design.l0_system.color.toComposeColor +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +@SuppressLint("StaticFieldLeak") +@HiltViewModel +internal class EditAccountViewModel @Inject constructor( + @ApplicationContext + private val appContext: Context, + private val itemIconAct: ItemIconAct, + private val writeAccountsAct: WriteAccountsAct, + private val accountByIdAct: AccountByIdAct, + private val accountFoldersFlow: AccountFoldersFlow, + private val mapFolderUiAct: MapFolderUiAct, + private val accBalanceFlow: AccBalanceFlow, + private val timeProvider: TimeProvider, +) : SimpleFlowViewModel() { + override val initialUi = EditAccountState( + accountId = "", + currency = "", + icon = ItemIcon.Sized( + iconS = R.drawable.ic_custom_account_s, + iconM = R.drawable.ic_custom_account_m, + iconL = R.drawable.ic_custom_account_l, + iconId = null + ), + color = Purple, + initialName = "", + folder = null, + excluded = false, + archived = false, + balance = Value(0.0, ""), + balanceUi = ValueUi("0.00", ""), + ) + + private val account = MutableStateFlow(null) + private var name = "" + private val initialName = MutableStateFlow(initialUi.initialName) + private val currency = MutableStateFlow(initialUi.currency) + private val iconId = MutableStateFlow(null) + private val color = MutableStateFlow(initialUi.color) + private val excluded = MutableStateFlow(initialUi.excluded) + private val folderId = MutableStateFlow(null) + private val archived = MutableStateFlow(initialUi.archived) + + override val uiFlow: Flow = combine( + account, headerFlow(), secondaryFlow(), folderFlow(), accountBalanceFlow() + ) { account, header, secondary, folder, balance -> + EditAccountState( + accountId = account?.id?.toString() ?: "", + currency = secondary.currency, + icon = itemIconAct(ItemIconAct.Input(header.iconId, DefaultTo.Account)), + initialName = header.initialName, + color = header.color, + excluded = secondary.excluded, + folder = folder, + archived = secondary.archived, + balance = balance ?: initialUi.balance, + balanceUi = balance?.let { format(it, shortenFiat = false) } ?: initialUi.balanceUi + ) + } + + private fun headerFlow(): Flow
= combine( + iconId, initialName, color, + ) { iconId, initialName, color -> + Header(iconId = iconId, initialName = initialName, color = color) + } + + private fun secondaryFlow(): Flow = combine( + currency, excluded, archived + ) { currency, excluded, archived -> + Secondary(currency, excluded, archived) + } + + private fun folderFlow(): Flow = combine( + accountFoldersFlow(Unit), folderId + ) { folders, folderId -> + folders.filterIsInstance() + .firstOrNull { it.accountFolder.id == folderId } + ?.let { mapFolderUiAct(it.accountFolder) } + } + + @OptIn(FlowPreview::class) + private fun accountBalanceFlow(): Flow = account.flatMapLatest { account -> + if (account != null) { + accBalanceFlow(AccBalanceFlow.Input(account)) + } else flowOf(null) + } + + // region Event Handling + override suspend fun handleEvent(event: EditAccountEvent) = when (event) { + is EditAccountEvent.Initial -> handleInitial(event) + EditAccountEvent.EditAccount -> editAccount() + is EditAccountEvent.IconChange -> handleIconPick(event) + is EditAccountEvent.NameChange -> handleNameChange(event) + is EditAccountEvent.CurrencyChange -> handleCurrencyChange(event) + is EditAccountEvent.ColorChange -> handleColorChange(event) + is EditAccountEvent.ExcludedChange -> handleExcludedChange(event) + is EditAccountEvent.FolderChange -> handleFolderChange(event) + EditAccountEvent.Archive -> handleArchive() + EditAccountEvent.Unarchive -> handleUnarchive() + EditAccountEvent.Delete -> handleDelete() + } + + private suspend fun handleInitial(event: EditAccountEvent.Initial) { + // we need a snapshot of the account at this given point in time + // => flow isn't good for that use-case + accountByIdAct(event.accountId)?.let { + account.value = it + name = it.name + initialName.value = it.name + currency.value = it.currency + iconId.value = it.icon + color.value = it.color.toComposeColor() + excluded.value = it.excluded + folderId.value = it.folderId?.toString() + archived.value = it.state == AccountState.Archived + } + } + + private suspend fun editAccount() { + val updatedAccount = account.value?.copy( + name = name, + currency = currency.value, + color = color.value.toArgb(), + folderId = folderId.value?.toUUID(), + excluded = excluded.value, + icon = iconId.value, + sync = Sync( + state = SyncState.Syncing, + lastUpdated = timeProvider.timeNow(), + ) + ) + if (updatedAccount != null) { + writeAccountsAct(Modify.save(updatedAccount)) + } + } + + private fun handleIconPick(event: EditAccountEvent.IconChange) { + iconId.value = event.iconId + } + + private fun handleNameChange(event: EditAccountEvent.NameChange) { + name = event.name + } + + private fun handleCurrencyChange(event: EditAccountEvent.CurrencyChange) { + currency.value = event.newCurrency + } + + private fun handleColorChange(event: EditAccountEvent.ColorChange) { + color.value = event.color + } + + private fun handleFolderChange(event: EditAccountEvent.FolderChange) { + folderId.value = event.folder?.id + } + + private fun handleExcludedChange(event: EditAccountEvent.ExcludedChange) { + excluded.value = event.excluded + } + + private suspend fun handleArchive() { + archived.value = true + updateArchived(state = AccountState.Archived) + showToast("Account archived") + } + + private suspend fun handleUnarchive() { + archived.value = false + updateArchived(state = AccountState.Default) + showToast("Account unarchived") + } + + private fun showToast(text: String) { + Toast.makeText(appContext, text, Toast.LENGTH_LONG).show() + } + + private suspend fun updateArchived(state: AccountState) { + val updatedAccount = account.value?.copy( + state = state, + sync = Sync( + state = SyncState.Syncing, + lastUpdated = timeProvider.timeNow(), + ) + ) + if (updatedAccount != null) { + writeAccountsAct(Modify.save(updatedAccount)) + } + } + + private suspend fun handleDelete() { + account.value?.let { + writeAccountsAct(Modify.delete(it.id.toString())) + } + } + // endregion + + private data class Header( + val iconId: ItemIconId?, + val initialName: String, + val color: Color, + ) + + private data class Secondary( + val currency: CurrencyCode, + val excluded: Boolean, + val archived: Boolean, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/edit/components/DeleteAccountModal.kt b/core/ui/src/main/java/com/ivy/core/ui/account/edit/components/DeleteAccountModal.kt new file mode 100644 index 0000000..c380de4 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/edit/components/DeleteAccountModal.kt @@ -0,0 +1,123 @@ +package com.ivy.core.ui.account.edit.components + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.R +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Body +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview + +@Composable +fun BoxScope.DeleteAccountModal( + modal: IvyModal, + level: Int = 1, + archived: Boolean, + accountName: String, + onArchive: () -> Unit, + onDelete: () -> Unit, +) { + Modal( + modal = modal, + level = level, + actions = { + IvyButton( + size = ButtonSize.Small, + visibility = Visibility.Focused, + feeling = Feeling.Negative, + text = "Delete forever", + icon = R.drawable.ic_round_delete_forever_24 + ) { + modal.hide() + onDelete() + } + } + ) { + Title( + text = "Delete \"$accountName\" account forever?", + color = UI.colors.red + ) + SpacerVer(height = 24.dp) + Body( + text = bodyText( + accountName = accountName, + archived = archived + ) + ) + if (!archived) { + SpacerVer(height = 12.dp) + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = "Archive", + icon = R.drawable.round_archive_24 + ) { + modal.hide() + onArchive() + } + } + SpacerVer(height = 48.dp) + } +} + +private fun bodyText( + accountName: String, + archived: Boolean +): String { + val baseText = "DANGER! Deleting \"$accountName\" account will delete all transactions" + + " in it forever. This operation CANNOT be undone and will affect your balance!" + + " Please, be careful otherwise you may lose your data." + + val unarchivedText = + "\n\nIf you don't want to see this account but want preserve its transactions," + + " a better option would be to just archive it." + return if (archived) baseText else baseText + unarchivedText +} + +// region Preview +@Preview +@Composable +private fun Preview_Unarchived() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + DeleteAccountModal( + modal = modal, + accountName = "Account 1", + archived = false, + onArchive = {}, + onDelete = {} + ) + } +} + +@Preview +@Composable +private fun Preview_Archived() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + DeleteAccountModal( + modal = modal, + accountName = "Account 1", + archived = true, + onArchive = {}, + onDelete = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/folder/BaseFolderModal.kt b/core/ui/src/main/java/com/ivy/core/ui/account/folder/BaseFolderModal.kt new file mode 100644 index 0000000..06b0c29 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/folder/BaseFolderModal.kt @@ -0,0 +1,198 @@ +package com.ivy.core.ui.account.folder + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.R +import com.ivy.core.ui.account.pick.AccountPickerColumn +import com.ivy.core.ui.color.ColorButton +import com.ivy.core.ui.color.picker.ColorPickerModal +import com.ivy.core.ui.component.ItemIconNameRow +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.core.ui.data.icon.dummyIconUnknown +import com.ivy.core.ui.icon.picker.IconPickerModal +import com.ivy.data.ItemIconId +import com.ivy.design.l0_system.color.Purple +import com.ivy.design.l1_buildingBlocks.B1 +import com.ivy.design.l1_buildingBlocks.DividerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Positive +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l2_components.modal.scope.ModalActionsScope +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.util.IvyPreview + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun BoxScope.BaseFolderModal( + modal: IvyModal, + level: Int, + autoFocusNameInput: Boolean, + title: String, + positiveButtonText: String, + secondaryActions: (@Composable ModalActionsScope.() -> Unit)? = null, + initialName: String, + icon: ItemIcon, + color: Color, + accounts: List, + onNameChane: (String) -> Unit, + onColorChange: (Color) -> Unit, + onIconChange: (ItemIconId) -> Unit, + onAccountsChange: (List) -> Unit, + onSave: (SaveFolderInfo) -> Unit, +) { + val iconPickerModal = rememberIvyModal() + val colorPickerModal = rememberIvyModal() + + val keyboardController = LocalSoftwareKeyboardController.current + Modal( + modal = modal, + level = level, + actions = { + secondaryActions?.invoke(this) + Positive( + text = positiveButtonText, + feeling = Feeling.Custom(color) + ) { + onSave(SaveFolderInfo(color)) + keyboardController?.hide() + modal.hide() + } + } + ) { + LazyColumn(modifier = Modifier.weight(1f)) { + item(key = "modal_title") { + Title(text = title) + SpacerVer(height = 24.dp) + } + item(key = "icon_name_color") { + // Keep in one item because so the title + // won't disappear on scroll + ItemIconNameRow( + icon = icon, + color = color, + initialName = initialName, + nameInputHint = "Folder name", + autoFocusInput = autoFocusNameInput, + onPickIcon = { + keyboardController?.hide() + iconPickerModal.show() + }, + onNameChange = onNameChane + ) + SpacerVer(height = 16.dp) + ColorButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + color = color + ) { + keyboardController?.hide() + colorPickerModal.show() + } + SpacerVer(height = 24.dp) + } + item(key = "accounts_in_folder") { + // Can't have create account modal + // because of infinite recursion + AccountsInFolder( + selected = accounts, + createAccountModal = null, + onSelectedChange = onAccountsChange + ) + } + } + } + + IconPickerModal( + modal = iconPickerModal, + level = level + 1, + initialIcon = icon, + color = color, + onIconPick = onIconChange, + ) + + ColorPickerModal( + modal = colorPickerModal, + level = level + 1, + initialColor = color, + onColorPicked = onColorChange, + ) +} + +data class SaveFolderInfo( + val color: Color, +) + +@Composable +private fun ColumnScope.AccountsInFolder( + selected: List, + createAccountModal: IvyModal?, + onSelectedChange: (List) -> Unit, +) { + DividerHor() + SpacerVer(height = 12.dp) + B1( + modifier = Modifier.padding(start = 24.dp), + text = "Accounts in folder", + fontWeight = FontWeight.ExtraBold + ) + SpacerVer(height = 12.dp) + AccountPickerColumn( + modifier = Modifier.padding(horizontal = 8.dp), + selected = selected, + deselectButton = true, + onAddAccount = null, + onSelectAccount = { + onSelectedChange(selected.plus(it)) + }, + onDeselectAccount = { deselected -> + onSelectedChange(selected.filter { it.id != deselected.id }) + } + ) + SpacerVer(height = 24.dp) + DividerHor() + SpacerVer(height = 48.dp) +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + BaseFolderModal( + modal = modal, + level = 1, + autoFocusNameInput = false, + title = "New folder", + positiveButtonText = "Add folder", + initialName = "", + icon = dummyIconUnknown(R.drawable.ic_vue_files_folder), + color = Purple, + accounts = listOf(), + onNameChane = {}, + onColorChange = {}, + onIconChange = {}, + onAccountsChange = {}, + onSave = {}, + ) + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/folder/create/CreateAccFolderEvent.kt b/core/ui/src/main/java/com/ivy/core/ui/account/folder/create/CreateAccFolderEvent.kt new file mode 100644 index 0000000..5597bbb --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/folder/create/CreateAccFolderEvent.kt @@ -0,0 +1,16 @@ +package com.ivy.core.ui.account.folder.create + +import androidx.compose.ui.graphics.Color +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.data.ItemIconId + +internal sealed interface CreateAccFolderEvent { + data class CreateFolder( + val color: Color, + val accounts: List, + ) : CreateAccFolderEvent + + data class NameChange(val name: String) : CreateAccFolderEvent + + data class IconChange(val iconId: ItemIconId) : CreateAccFolderEvent +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/folder/create/CreateAccFolderModal.kt b/core/ui/src/main/java/com/ivy/core/ui/account/folder/create/CreateAccFolderModal.kt new file mode 100644 index 0000000..44e6a0d --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/folder/create/CreateAccFolderModal.kt @@ -0,0 +1,71 @@ +package com.ivy.core.ui.account.folder.create + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.* +import androidx.compose.ui.tooling.preview.Preview +import com.ivy.core.ui.R +import com.ivy.core.ui.account.folder.BaseFolderModal +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.design.l0_system.UI +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.hiltViewModelPreviewSafe + +@Composable +fun BoxScope.CreateAccFolderModal( + modal: IvyModal, + level: Int = 1, +) { + val viewModel: CreateAccFolderViewModel? = hiltViewModelPreviewSafe() + val state = viewModel?.uiState?.collectAsState()?.value ?: previewState() + + val primary = UI.colors.primary + var folderColor by remember(primary) { mutableStateOf(primary) } + var accounts by remember { mutableStateOf>(emptyList()) } + + BaseFolderModal( + modal = modal, + level = level, + autoFocusNameInput = true, + title = "New folder", + positiveButtonText = "Add folder", + initialName = "", + icon = state.icon, + color = folderColor, + accounts = accounts, + onNameChane = { viewModel?.onEvent(CreateAccFolderEvent.NameChange(it)) }, + onColorChange = { folderColor = it }, + onIconChange = { viewModel?.onEvent(CreateAccFolderEvent.IconChange(it)) }, + onAccountsChange = { accounts = it }, + onSave = { + viewModel?.onEvent( + CreateAccFolderEvent.CreateFolder( + color = folderColor, + accounts = accounts + ) + ) + } + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + CreateAccFolderModal(modal = modal) + } +} + +private fun previewState() = CreateAccFolderState( + icon = ItemIcon.Unknown( + icon = R.drawable.ic_vue_files_folder, + iconId = "ic_vue_files_folder", + ) +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/folder/create/CreateAccFolderState.kt b/core/ui/src/main/java/com/ivy/core/ui/account/folder/create/CreateAccFolderState.kt new file mode 100644 index 0000000..2de5685 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/folder/create/CreateAccFolderState.kt @@ -0,0 +1,9 @@ +package com.ivy.core.ui.account.folder.create + +import androidx.compose.runtime.Immutable +import com.ivy.core.ui.data.icon.ItemIcon + +@Immutable +internal data class CreateAccFolderState( + val icon: ItemIcon +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/folder/create/CreateAccFolderViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/account/folder/create/CreateAccFolderViewModel.kt new file mode 100644 index 0000000..efe173b --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/folder/create/CreateAccFolderViewModel.kt @@ -0,0 +1,86 @@ +package com.ivy.core.ui.account.folder.create + +import androidx.compose.ui.graphics.toArgb +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.action.account.NewAccountTabItemOrderNumAct +import com.ivy.core.domain.action.account.folder.WriteAccountFolderAct +import com.ivy.core.domain.action.account.folder.WriteAccountFolderContentAct +import com.ivy.core.domain.action.data.Modify +import com.ivy.core.ui.R +import com.ivy.core.ui.action.DefaultTo +import com.ivy.core.ui.action.ItemIconAct +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.data.ItemIconId +import com.ivy.data.Sync +import com.ivy.data.SyncState +import com.ivy.data.account.AccountFolder +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import java.util.* +import javax.inject.Inject + +@HiltViewModel +internal class CreateAccFolderViewModel @Inject constructor( + private val itemIconAct: ItemIconAct, + private val writeAccountFolderAct: WriteAccountFolderAct, + private val writeAccountFolderContentAct: WriteAccountFolderContentAct, + private val newAccountTabItemOrderNumAct: NewAccountTabItemOrderNumAct, + private val timeProvider: TimeProvider, +) : SimpleFlowViewModel() { + override val initialUi = CreateAccFolderState( + icon = ItemIcon.Unknown( + icon = R.drawable.ic_vue_files_folder, + iconId = "ic_vue_files_folder", + ) + ) + + private var folderName = "" + private val folderIconId = MutableStateFlow(null) + + override val uiFlow: Flow = folderIconId.map { iconId -> + CreateAccFolderState( + icon = itemIconAct(ItemIconAct.Input(iconId, DefaultTo.Folder)) + ) + } + + + // region Event Handling + override suspend fun handleEvent(event: CreateAccFolderEvent) = when (event) { + is CreateAccFolderEvent.CreateFolder -> handleCreateFolder(event) + is CreateAccFolderEvent.NameChange -> handleFolderNameChange(event) + is CreateAccFolderEvent.IconChange -> handleIconChange(event) + } + + private suspend fun handleCreateFolder(event: CreateAccFolderEvent.CreateFolder) { + val newAccountFolder = AccountFolder( + id = UUID.randomUUID().toString(), + name = folderName, + icon = folderIconId.value, + color = event.color.toArgb(), + orderNum = newAccountTabItemOrderNumAct(Unit), + sync = Sync( + state = SyncState.Syncing, + lastUpdated = timeProvider.timeNow(), + ) + ) + writeAccountFolderAct(Modify.save(newAccountFolder)) + writeAccountFolderContentAct( + WriteAccountFolderContentAct.Input( + folderId = newAccountFolder.id, + accountIds = event.accounts.map { it.id } + ) + ) + } + + private fun handleFolderNameChange(event: CreateAccFolderEvent.NameChange) { + folderName = event.name + } + + private fun handleIconChange(event: CreateAccFolderEvent.IconChange) { + folderIconId.value = event.iconId + } + // endregion +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/folder/edit/EditAccFolderEvent.kt b/core/ui/src/main/java/com/ivy/core/ui/account/folder/edit/EditAccFolderEvent.kt new file mode 100644 index 0000000..74cdad9 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/folder/edit/EditAccFolderEvent.kt @@ -0,0 +1,21 @@ +package com.ivy.core.ui.account.folder.edit + +import androidx.compose.ui.graphics.Color +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.data.ItemIconId + +internal sealed interface EditAccFolderEvent { + data class Initial(val folderId: String) : EditAccFolderEvent + + object EditFolder : EditAccFolderEvent + + data class NameChange(val name: String) : EditAccFolderEvent + + data class IconChange(val iconId: ItemIconId) : EditAccFolderEvent + + data class ColorChange(val color: Color) : EditAccFolderEvent + + data class AccountsChange(val accounts: List) : EditAccFolderEvent + + object Delete : EditAccFolderEvent +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/folder/edit/EditAccFolderModal.kt b/core/ui/src/main/java/com/ivy/core/ui/account/folder/edit/EditAccFolderModal.kt new file mode 100644 index 0000000..f9a1569 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/folder/edit/EditAccFolderModal.kt @@ -0,0 +1,114 @@ +package com.ivy.core.ui.account.folder.edit + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.account.folder.BaseFolderModal +import com.ivy.core.ui.data.icon.dummyIconUnknown +import com.ivy.core.ui.uiStatePreviewSafe +import com.ivy.design.l0_system.color.Purple +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.l3_ivyComponents.modal.DeleteConfirmationModal +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.hiltViewModelPreviewSafe +import com.ivy.resources.R + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun BoxScope.EditAccFolderModal( + modal: IvyModal, + folderId: String, + level: Int = 1, +) { + val viewModel: EditAccFolderViewModel? = hiltViewModelPreviewSafe() + val state = uiStatePreviewSafe(viewModel = viewModel, preview = ::previewState) + + LaunchedEffect(folderId) { + viewModel?.onEvent(EditAccFolderEvent.Initial(folderId)) + } + + val deleteConfirmationModal = rememberIvyModal() + + val keyboardController = LocalSoftwareKeyboardController.current + BaseFolderModal( + modal = modal, + level = level, + autoFocusNameInput = false, + title = "Edit folder", + positiveButtonText = stringResource(R.string.save), + secondaryActions = { + DeleteButton { + keyboardController?.hide() + deleteConfirmationModal.show() + } + SpacerHor(width = 12.dp) + }, + initialName = state.initialName, + icon = state.icon, + color = state.color, + accounts = state.accounts, + onNameChane = { viewModel?.onEvent(EditAccFolderEvent.NameChange(it)) }, + onColorChange = { viewModel?.onEvent(EditAccFolderEvent.ColorChange(it)) }, + onIconChange = { viewModel?.onEvent(EditAccFolderEvent.IconChange(it)) }, + onAccountsChange = { viewModel?.onEvent(EditAccFolderEvent.AccountsChange(it)) }, + onSave = { + viewModel?.onEvent(EditAccFolderEvent.EditFolder) + } + ) + + DeleteConfirmationModal( + modal = deleteConfirmationModal, + level = level + 1, + ) { + modal.hide() + viewModel?.onEvent(EditAccFolderEvent.Delete) + } +} + +@Composable +private fun DeleteButton( + onClick: () -> Unit, +) { + IvyButton( + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = Feeling.Negative, + text = null, + icon = R.drawable.outline_delete_24, + onClick = onClick, + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + EditAccFolderModal( + modal = modal, + folderId = "", + ) + } +} + +private fun previewState() = EditAccFolderState( + icon = dummyIconUnknown(R.drawable.ic_vue_files_folder), + color = Purple, + initialName = "Folder 1", + accounts = listOf(), +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/folder/edit/EditAccFolderState.kt b/core/ui/src/main/java/com/ivy/core/ui/account/folder/edit/EditAccFolderState.kt new file mode 100644 index 0000000..c75e3e0 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/folder/edit/EditAccFolderState.kt @@ -0,0 +1,14 @@ +package com.ivy.core.ui.account.folder.edit + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.icon.ItemIcon + +@Immutable +internal data class EditAccFolderState( + val icon: ItemIcon, + val color: Color, + val initialName: String, + val accounts: List, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/folder/edit/EditAccFolderViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/account/folder/edit/EditAccFolderViewModel.kt new file mode 100644 index 0000000..fd61770 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/folder/edit/EditAccFolderViewModel.kt @@ -0,0 +1,133 @@ +package com.ivy.core.ui.account.folder.edit + +import androidx.compose.ui.graphics.toArgb +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.action.account.folder.AccountsInFolderAct +import com.ivy.core.domain.action.account.folder.FolderAct +import com.ivy.core.domain.action.account.folder.WriteAccountFolderAct +import com.ivy.core.domain.action.account.folder.WriteAccountFolderContentAct +import com.ivy.core.domain.action.data.Modify +import com.ivy.core.ui.R +import com.ivy.core.ui.action.DefaultTo +import com.ivy.core.ui.action.ItemIconAct +import com.ivy.core.ui.action.mapping.account.MapAccountUiAct +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.data.ItemIconId +import com.ivy.data.Sync +import com.ivy.data.SyncState +import com.ivy.data.account.AccountFolder +import com.ivy.design.l0_system.color.Purple +import com.ivy.design.l0_system.color.toComposeColor +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +@HiltViewModel +internal class EditAccFolderViewModel @Inject constructor( + private val itemIconAct: ItemIconAct, + private val writeAccountFolderAct: WriteAccountFolderAct, + private val writeAccountFolderContentAct: WriteAccountFolderContentAct, + private val folderAct: FolderAct, + private val accountsInFolderAct: AccountsInFolderAct, + private val mapAccountUiAct: MapAccountUiAct, + private val timeProvider: TimeProvider, +) : SimpleFlowViewModel() { + override val initialUi = EditAccFolderState( + icon = ItemIcon.Unknown( + icon = R.drawable.ic_vue_files_folder, + iconId = "ic_vue_files_folder", + ), + color = Purple, + initialName = "", + accounts = emptyList(), + ) + + private var accountFolder: AccountFolder? = null + private var folderName = "" + private val initialName = MutableStateFlow(initialUi.initialName) + private val iconId = MutableStateFlow(null) + private val color = MutableStateFlow(initialUi.color) + private val accounts = MutableStateFlow(initialUi.accounts) + + override val uiFlow: Flow = combine( + initialName, iconId, color, accounts + ) { initialName, iconId, color, accounts -> + EditAccFolderState( + initialName = initialName, + icon = itemIconAct(ItemIconAct.Input(iconId, DefaultTo.Folder)), + color = color, + accounts = accounts + ) + } + + + // region Event Handling + override suspend fun handleEvent(event: EditAccFolderEvent) = when (event) { + is EditAccFolderEvent.Initial -> handleInitial(event) + is EditAccFolderEvent.EditFolder -> handleEditFolder() + is EditAccFolderEvent.NameChange -> handleFolderNameChange(event) + is EditAccFolderEvent.IconChange -> handleIconChange(event) + is EditAccFolderEvent.ColorChange -> handleColorChange(event) + is EditAccFolderEvent.AccountsChange -> handleAccountsChange(event) + EditAccFolderEvent.Delete -> handleDelete() + } + + private suspend fun handleInitial(event: EditAccFolderEvent.Initial) { + folderAct(event.folderId)?.let { folder -> + this.accountFolder = folder + folderName = folder.name + initialName.value = folder.name + iconId.value = folder.icon + color.value = folder.color.toComposeColor() + accounts.value = accountsInFolderAct(folder.id) + .map { mapAccountUiAct(it) } + } + } + + private suspend fun handleEditFolder() { + val updated = accountFolder?.copy( + name = folderName, + color = color.value.toArgb(), + icon = iconId.value, + sync = Sync( + state = SyncState.Syncing, + lastUpdated = timeProvider.timeNow(), + ) + ) + if (updated != null) { + writeAccountFolderAct(Modify.save(updated)) + writeAccountFolderContentAct( + WriteAccountFolderContentAct.Input( + folderId = updated.id, + accountIds = accounts.value.map { it.id } + ) + ) + } + } + + private fun handleFolderNameChange(event: EditAccFolderEvent.NameChange) { + folderName = event.name + } + + private fun handleIconChange(event: EditAccFolderEvent.IconChange) { + iconId.value = event.iconId + } + + private fun handleColorChange(event: EditAccFolderEvent.ColorChange) { + color.value = event.color + } + + private fun handleAccountsChange(event: EditAccFolderEvent.AccountsChange) { + accounts.value = event.accounts + } + + private suspend fun handleDelete() { + accountFolder?.let { + writeAccountFolderAct(Modify.delete(it.id)) + } + } + // endregion +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/folder/pick/FolderPickerModal.kt b/core/ui/src/main/java/com/ivy/core/ui/account/folder/pick/FolderPickerModal.kt new file mode 100644 index 0000000..beff8a0 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/folder/pick/FolderPickerModal.kt @@ -0,0 +1,191 @@ +package com.ivy.core.ui.account.folder.pick + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.R +import com.ivy.core.ui.account.folder.create.CreateAccFolderModal +import com.ivy.core.ui.data.account.FolderUi +import com.ivy.core.ui.data.account.dummyFolderUi +import com.ivy.core.ui.data.icon.IconSize +import com.ivy.core.ui.icon.ItemIcon +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.* +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Negative +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.hiltViewModelPreviewSafe +import com.ivy.design.util.thenWhen + +@Composable +fun BoxScope.FolderPickerModal( + modal: IvyModal, + selected: FolderUi?, + level: Int = 1, + onPickFolder: (FolderUi?) -> Unit, +) { + val viewModel: FolderPickerViewModel? = hiltViewModelPreviewSafe() + val state = viewModel?.uiState?.collectAsState()?.value + ?: previewState() + + val createFolderModal = rememberIvyModal() + + Modal( + modal = modal, + level = level, + actions = { + Negative(text = "No folder") { + onPickFolder(null) + modal.hide() + } + } + ) { + LazyColumn { + item { + Title(text = "Choose folder") + } + folderItems( + folders = state.folders, + selected = selected, + onSelect = { + onPickFolder(it) + modal.hide() + } + ) + createFolderItem( + onCreateFolder = { createFolderModal.show() } + ) + item { + SpacerVer(height = 48.dp) // last item spacer + } + } + } + + CreateAccFolderModal( + modal = createFolderModal, + level = level + 1, + ) +} + +// region Folders +private fun LazyListScope.folderItems( + folders: List, + selected: FolderUi?, + onSelect: (FolderUi) -> Unit +) { + items( + items = folders, + key = { "folder_${it.id}" } + ) { folder -> + SpacerVer(height = 12.dp) + FolderItem( + folder = folder, + selected = folder.id == selected?.id + ) { + onSelect(folder) + } + } +} + +@Composable +internal fun FolderItem( + folder: FolderUi, + selected: Boolean, + onClick: () -> Unit +) { + val dynamicContrast = rememberDynamicContrast(folder.color) + val contrastColor = rememberContrast(folder.color) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(UI.shapes.squared) + .thenWhen { + when (selected) { + true -> background(folder.color, UI.shapes.squared) + .border(2.dp, dynamicContrast, UI.shapes.squared) + false -> border(2.dp, dynamicContrast, UI.shapes.squared) + } + } + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val color = if (selected) contrastColor else UI.colorsInverted.pure + ItemIcon( + itemIcon = folder.icon, + size = IconSize.S, + tint = color, + ) + SpacerHor(width = 8.dp) + B2(text = folder.name, color = color) + } +} +// endregion + +// region Add folder +fun LazyListScope.createFolderItem( + onCreateFolder: () -> Unit, +) { + item(key = "add_folder_item") { + SpacerVer(height = 12.dp) + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = "New folder", + icon = R.drawable.ic_round_add_24, + onClick = onCreateFolder, + ) + } +} +// endregion + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + FolderPickerModal( + modal = modal, + selected = dummyFolderUi(id = "folder1"), + onPickFolder = {} + ) + } +} + +private fun previewState() = FolderPickerState( + folders = listOf( + dummyFolderUi(id = "folder1", name = "Folder 1", color = Green), + dummyFolderUi("Folder 2", color = Yellow), + dummyFolderUi("Folder 3", color = Purple), + ) +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/folder/pick/FolderPickerState.kt b/core/ui/src/main/java/com/ivy/core/ui/account/folder/pick/FolderPickerState.kt new file mode 100644 index 0000000..5c10ebc --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/folder/pick/FolderPickerState.kt @@ -0,0 +1,9 @@ +package com.ivy.core.ui.account.folder.pick + +import androidx.compose.runtime.Immutable +import com.ivy.core.ui.data.account.FolderUi + +@Immutable +data class FolderPickerState( + val folders: List +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/folder/pick/FolderPickerViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/account/folder/pick/FolderPickerViewModel.kt new file mode 100644 index 0000000..d04251b --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/folder/pick/FolderPickerViewModel.kt @@ -0,0 +1,31 @@ +package com.ivy.core.ui.account.folder.pick + +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.action.account.folder.AccountFoldersFlow +import com.ivy.core.domain.action.data.AccountListItem +import com.ivy.core.ui.action.mapping.account.MapFolderUiAct +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@HiltViewModel +class FolderPickerViewModel @Inject constructor( + accountsFoldersFlow: AccountFoldersFlow, + private val mapFolderUiAct: MapFolderUiAct +) : SimpleFlowViewModel() { + override val initialUi = FolderPickerState(folders = emptyList()) + + override val uiFlow: Flow = + accountsFoldersFlow(Unit).map { accountsFolders -> + FolderPickerState( + folders = accountsFolders + .filterIsInstance() + .map { mapFolderUiAct(it.accountFolder) } + ) + } + + // region Event Handling + override suspend fun handleEvent(event: Unit) {} + // endregion +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/pick/AccountPickerColumn.kt b/core/ui/src/main/java/com/ivy/core/ui/account/pick/AccountPickerColumn.kt new file mode 100644 index 0000000..5e275ff --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/pick/AccountPickerColumn.kt @@ -0,0 +1,127 @@ +package com.ivy.core.ui.account.pick + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.R +import com.ivy.core.ui.account.pick.component.SelectableAccountItem +import com.ivy.core.ui.account.pick.data.SelectableAccountUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.core.ui.uiStatePreviewSafe +import com.ivy.design.l0_system.color.* +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.WrapContentRow +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.hiltViewModelPreviewSafe + +@Composable +fun ColumnScope.AccountPickerColumn( + selected: List, + deselectButton: Boolean, + modifier: Modifier = Modifier, + onAddAccount: (() -> Unit)?, + onSelectAccount: (AccountUi) -> Unit, + onDeselectAccount: (AccountUi) -> Unit, +) { + val viewModel: AccountPickerViewModel? = hiltViewModelPreviewSafe() + val state = uiStatePreviewSafe(viewModel = viewModel, preview = ::previewState) + + LaunchedEffect(selected) { + viewModel?.onEvent(AccountPickerEvent.SelectedChange(selected)) + } + + WrapContentRow( + modifier = modifier, + items = state.accounts, + itemKey = { it.account.id }, + horizontalMarginBetweenItems = 8.dp, + verticalMarginBetweenRows = 12.dp + ) { item -> + SelectableAccountItem( + item = item, + deselectButton = deselectButton, + onSelect = { onSelectAccount(item.account) }, + onDeselect = { onDeselectAccount(item.account) } + ) + } + if (onAddAccount != null) { + SpacerVer(height = 12.dp) + IvyButton( + modifier = modifier, + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = stringResource(R.string.add_account), + icon = R.drawable.ic_round_add_24, + onClick = onAddAccount + ) + } +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + ComponentPreview { + Column { + AccountPickerColumn( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + selected = listOf(), + deselectButton = true, + onAddAccount = {}, + onSelectAccount = {}, + onDeselectAccount = {}, + ) + } + } +} + +@Preview +@Composable +private fun Preview_noDeselect() { + ComponentPreview { + Column { + AccountPickerColumn( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + selected = listOf(), + deselectButton = false, + onAddAccount = {}, + onSelectAccount = {}, + onDeselectAccount = {}, + ) + } + } +} + + +private fun previewState() = AccountPickerState( + accounts = listOf( + dummyAccountUi("Account 1"), + dummyAccountUi("Account 2", color = Blue), + dummyAccountUi("DSK", color = Green), + dummyAccountUi("Unicredit Bulbank", color = Red), + dummyAccountUi("Revolut", color = Purple2Dark), + dummyAccountUi("Investments", color = Green2Dark), + dummyAccountUi("Cash", color = Green2Dark), + ).mapIndexed { index, acc -> + SelectableAccountUi(acc, selected = index % 3 == 0) + } +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/pick/AccountPickerEvent.kt b/core/ui/src/main/java/com/ivy/core/ui/account/pick/AccountPickerEvent.kt new file mode 100644 index 0000000..9a54fb4 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/pick/AccountPickerEvent.kt @@ -0,0 +1,7 @@ +package com.ivy.core.ui.account.pick + +import com.ivy.core.ui.data.account.AccountUi + +sealed interface AccountPickerEvent { + data class SelectedChange(val selected: List) : AccountPickerEvent +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/pick/AccountPickerModal.kt b/core/ui/src/main/java/com/ivy/core/ui/account/pick/AccountPickerModal.kt new file mode 100644 index 0000000..0eaff80 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/pick/AccountPickerModal.kt @@ -0,0 +1,88 @@ +package com.ivy.core.ui.account.pick + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.R +import com.ivy.core.ui.account.create.CreateAccountModal +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.previewModal +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.util.IvyPreview + +@Composable +fun BoxScope.AccountPickerModal( + modal: IvyModal, + level: Int = 1, + selected: List, + deselectButton: Boolean, + onSelectAccount: (AccountUi) -> Unit, + onDeselectAccount: (AccountUi) -> Unit, +) { + val createAccountModal = rememberIvyModal() + + Modal( + modal = modal, + level = level, + actions = { + // no actions + } + ) { + LazyColumn( + modifier = Modifier + ) { + item(key = "title") { + Title( + text = stringResource(id = R.string.account), + paddingStart = 24.dp, + ) + SpacerVer(height = 16.dp) + } + item(key = "accounts") { + AccountPickerColumn( + modifier = Modifier.padding(horizontal = 8.dp), + selected = selected, + deselectButton = deselectButton, + onAddAccount = { + createAccountModal.show() + }, + onSelectAccount = onSelectAccount, + onDeselectAccount = onDeselectAccount + ) + } + item(key = "last_item_spacer") { + SpacerVer(height = 24.dp) + } + } + } + + CreateAccountModal( + modal = createAccountModal, + level = level + 1, + ) +} + + +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = previewModal() + AccountPickerModal( + modal = modal, + selected = emptyList(), + deselectButton = false, + onSelectAccount = {}, + onDeselectAccount = {}, + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/pick/AccountPickerState.kt b/core/ui/src/main/java/com/ivy/core/ui/account/pick/AccountPickerState.kt new file mode 100644 index 0000000..7fe1dff --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/pick/AccountPickerState.kt @@ -0,0 +1,9 @@ +package com.ivy.core.ui.account.pick + +import androidx.compose.runtime.Immutable +import com.ivy.core.ui.account.pick.data.SelectableAccountUi + +@Immutable +data class AccountPickerState( + val accounts: List, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/pick/AccountPickerViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/account/pick/AccountPickerViewModel.kt new file mode 100644 index 0000000..1eabc00 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/pick/AccountPickerViewModel.kt @@ -0,0 +1,44 @@ +package com.ivy.core.ui.account.pick + +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.action.account.AccountsFlow +import com.ivy.core.ui.account.pick.data.SelectableAccountUi +import com.ivy.core.ui.action.mapping.account.MapAccountUiAct +import com.ivy.data.account.AccountState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +@HiltViewModel +class AccountPickerViewModel @Inject constructor( + accountsFlow: AccountsFlow, + private val mapAccountUiAct: MapAccountUiAct, +) : SimpleFlowViewModel() { + override val initialUi = AccountPickerState( + accounts = emptyList() + ) + + private val selectedIds = MutableStateFlow(listOf()) + + override val uiFlow: Flow = combine( + accountsFlow(), selectedIds + ) { accounts, selectedIds -> + AccountPickerState( + accounts = accounts.filter { it.state != AccountState.Archived } + .map { mapAccountUiAct(it) } + .map { SelectableAccountUi(it, selected = selectedIds.contains(it.id)) } + ) + } + + // region Event Handling + override suspend fun handleEvent(event: AccountPickerEvent) = when (event) { + is AccountPickerEvent.SelectedChange -> handleSelectedChange(event) + } + + private fun handleSelectedChange(event: AccountPickerEvent.SelectedChange) { + selectedIds.value = event.selected.map { it.id } + } + // endregion +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/pick/AccountsState.kt b/core/ui/src/main/java/com/ivy/core/ui/account/pick/AccountsState.kt new file mode 100644 index 0000000..8289d8f --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/pick/AccountsState.kt @@ -0,0 +1,9 @@ +package com.ivy.core.ui.account.pick + +import androidx.compose.runtime.Immutable +import com.ivy.core.ui.data.account.AccountUi + +@Immutable +data class AccountsState( + val accounts: List +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/pick/AccountsViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/account/pick/AccountsViewModel.kt new file mode 100644 index 0000000..2bbd441 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/pick/AccountsViewModel.kt @@ -0,0 +1,32 @@ +package com.ivy.core.ui.account.pick + +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.action.account.AccountsFlow +import com.ivy.core.ui.action.mapping.account.MapAccountUiAct +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@HiltViewModel +class AccountsViewModel @Inject constructor( + accountsFlow: AccountsFlow, + private val mapAccountUiAct: MapAccountUiAct, +) : SimpleFlowViewModel() { + override val initialUi = AccountsState( + accounts = emptyList(), + ) + + override val uiFlow: Flow = accountsFlow() + .map { accounts -> + AccountsState( + accounts = accounts.map { + mapAccountUiAct(it) + }, + ) + } + + + override suspend fun handleEvent(event: Unit) { + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/pick/SingleAccountPickerModal.kt b/core/ui/src/main/java/com/ivy/core/ui/account/pick/SingleAccountPickerModal.kt new file mode 100644 index 0000000..a8bfc6a --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/pick/SingleAccountPickerModal.kt @@ -0,0 +1,55 @@ +package com.ivy.core.ui.account.pick + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.ivy.core.ui.account.create.CreateAccountModal +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.previewModal +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.util.IvyPreview + +@Composable +fun BoxScope.SingleAccountPickerModal( + modal: IvyModal, + level: Int = 1, + selected: AccountUi, + onSelectAccount: (AccountUi) -> Unit, +) { + val createAccountModal = rememberIvyModal() + + AccountPickerModal( + modal = modal, + level = level, + selected = listOf(selected), + deselectButton = false, + onSelectAccount = { + onSelectAccount(it) + modal.hide() + }, + onDeselectAccount = { + // do nothing + } + ) + + CreateAccountModal( + modal = createAccountModal, + level = level + 1, + ) +} + + +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = previewModal() + SingleAccountPickerModal( + modal = modal, + selected = dummyAccountUi(), + onSelectAccount = {} + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/pick/SingleAccountPickerRow.kt b/core/ui/src/main/java/com/ivy/core/ui/account/pick/SingleAccountPickerRow.kt new file mode 100644 index 0000000..e9bcfd7 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/pick/SingleAccountPickerRow.kt @@ -0,0 +1,136 @@ +package com.ivy.core.ui.account.pick + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.R +import com.ivy.core.ui.account.pick.component.SelectableAccountItem +import com.ivy.core.ui.account.pick.data.SelectableAccountUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.core.ui.lastItemSpacerHorizontal +import com.ivy.core.ui.uiStatePreviewSafe +import com.ivy.design.l0_system.color.* +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.hiltViewModelPreviewSafe + +@Composable +fun SingleAccountPickerRow( + modifier: Modifier = Modifier, + selected: AccountUi, + onAddAccount: () -> Unit, + onSelectedChange: (AccountUi) -> Unit, +) { + val viewModel: AccountsViewModel? = hiltViewModelPreviewSafe() + val state = uiStatePreviewSafe(viewModel = viewModel, preview = ::previewState) + + val listState = rememberLazyListState() + + LaunchedEffect(selected, state.accounts) { + state.accounts.indexOfFirst { it.id == selected.id } + .let { index -> + if (index != -1) { + listState.animateScrollToItem(index) + } + } + } + + LazyRow( + modifier = modifier.fillMaxWidth(), + state = listState, + verticalAlignment = Alignment.CenterVertically, + ) { + accountItems( + items = state.accounts, + selected = selected, + onSelectedChange = onSelectedChange, + ) + addAccount(onAddAccount) + lastItemSpacerHorizontal(width = 12.dp) + } +} + +private fun LazyListScope.accountItems( + items: List, + selected: AccountUi, + onSelectedChange: (AccountUi) -> Unit, +) { + items( + items = items, + key = { it.id } + ) { item -> + SpacerHor(width = 8.dp) + SelectableAccountItem( + item = SelectableAccountUi( + account = item, + selected = item.id == selected.id, + ), + deselectButton = false, + onSelect = { onSelectedChange(item) }, + onDeselect = { + // do nothing because we always want to have a selected account + } + ) + } +} + +private fun LazyListScope.addAccount( + onClick: () -> Unit +) { + item(key = "add_account") { + SpacerHor(width = 8.dp) + IvyButton( + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = stringResource(R.string.add_account), + icon = R.drawable.ic_round_add_24, + onClick = onClick + ) + } +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + ComponentPreview { + SingleAccountPickerRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + selected = dummyAccountUi(), + onAddAccount = {}, + onSelectedChange = {}, + ) + } +} + +private fun previewState() = AccountsState( + accounts = listOf( + dummyAccountUi("Account 1"), + dummyAccountUi("Account 2", color = Blue), + dummyAccountUi("DSK", color = Green), + dummyAccountUi("Unicredit Bulbank", color = Red), + dummyAccountUi("Revolut", color = Purple2Dark), + dummyAccountUi("Investments", color = Green2Dark), + dummyAccountUi("Cash", color = Green2Dark), + ) +) +// endregion diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/pick/component/SelectableAccountItem.kt b/core/ui/src/main/java/com/ivy/core/ui/account/pick/component/SelectableAccountItem.kt new file mode 100644 index 0000000..f63eed3 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/pick/component/SelectableAccountItem.kt @@ -0,0 +1,43 @@ +package com.ivy.core.ui.account.pick.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.ivy.core.ui.account.pick.data.SelectableAccountUi +import com.ivy.core.ui.component.SelectableItem +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.design.util.ComponentPreview + +@Composable +fun SelectableAccountItem( + item: SelectableAccountUi, + deselectButton: Boolean, + onSelect: () -> Unit, + onDeselect: () -> Unit, +) { + SelectableItem( + name = item.account.name, + icon = item.account.icon, + color = item.account.color, + selected = item.selected, + deselectButton = deselectButton, + onSelect = onSelect, + onDeselect = onDeselect, + ) +} + + +@Preview +@Composable +private fun Preview() { + ComponentPreview { + SelectableAccountItem( + item = SelectableAccountUi( + account = dummyAccountUi(), + selected = true, + ), + deselectButton = true, + onSelect = {}, + onDeselect = {}, + ) + } +} diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/pick/data/SelectableAccountUi.kt b/core/ui/src/main/java/com/ivy/core/ui/account/pick/data/SelectableAccountUi.kt new file mode 100644 index 0000000..3595746 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/pick/data/SelectableAccountUi.kt @@ -0,0 +1,10 @@ +package com.ivy.core.ui.account.pick.data + +import androidx.compose.runtime.Immutable +import com.ivy.core.ui.data.account.AccountUi + +@Immutable +data class SelectableAccountUi( + val account: AccountUi, + val selected: Boolean +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/reorder/ReorderAccountsEvent.kt b/core/ui/src/main/java/com/ivy/core/ui/account/reorder/ReorderAccountsEvent.kt new file mode 100644 index 0000000..6848c15 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/reorder/ReorderAccountsEvent.kt @@ -0,0 +1,9 @@ +package com.ivy.core.ui.account.reorder + +import com.ivy.core.ui.account.reorder.data.ReorderAccListItemUi + +sealed interface ReorderAccountsEvent { + data class Reorder( + val reordered: List + ) : ReorderAccountsEvent +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/reorder/ReorderAccountsModal.kt b/core/ui/src/main/java/com/ivy/core/ui/account/reorder/ReorderAccountsModal.kt new file mode 100644 index 0000000..1b3dc29 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/reorder/ReorderAccountsModal.kt @@ -0,0 +1,133 @@ +package com.ivy.core.ui.account.reorder + +import ReorderModal +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.account.reorder.data.ReorderAccListItemUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.account.FolderUi +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.core.ui.data.account.dummyFolderUi +import com.ivy.core.ui.data.icon.IconSize +import com.ivy.core.ui.icon.ItemIcon +import com.ivy.core.ui.uiStatePreviewSafe +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.* +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l1_buildingBlocks.DividerW +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.previewModal +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.hiltViewModelPreviewSafe + +@Composable +fun BoxScope.ReorderAccountsModal( + modal: IvyModal, + level: Int = 1, +) { + val viewModel: ReorderAccountsViewModel? = hiltViewModelPreviewSafe() + val state = uiStatePreviewSafe(viewModel, preview = ::previewState) + + ReorderModal( + modal = modal, + level = level, + items = state.items, + onReorder = { + viewModel?.onEvent(ReorderAccountsEvent.Reorder(it)) + } + ) { _, item -> + Item(item = item) + } +} + +@Composable +private fun RowScope.Item(item: ReorderAccListItemUi) { + when (item) { + is ReorderAccListItemUi.AccountHolder -> AccountCard(account = item.account) + is ReorderAccListItemUi.FolderHolder -> FolderCard(folder = item.folder) + ReorderAccListItemUi.FolderEnd -> FolderEnd() + } +} + +@Composable +private fun AccountCard(account: AccountUi) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) // margin top + .padding(start = 8.dp, end = 16.dp) + .background(account.color, UI.shapes.rounded) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val contrast = rememberContrast(account.color) + ItemIcon(itemIcon = account.icon, size = IconSize.S, tint = contrast) + SpacerHor(width = 4.dp) + B2(text = account.name, color = contrast) + } +} + +@Composable +private fun FolderCard(folder: FolderUi) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) // margin top + .padding(start = 8.dp, end = 16.dp) + .background(folder.color, UI.shapes.squared) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val contrast = rememberContrast(folder.color) + ItemIcon(itemIcon = folder.icon, size = IconSize.S, tint = contrast) + SpacerHor(width = 4.dp) + B2(text = folder.name, color = contrast) + } +} + +@Composable +private fun RowScope.FolderEnd() { + SpacerHor(width = 8.dp) + DividerW() + SpacerHor(width = 8.dp) +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = previewModal() + ReorderAccountsModal(modal = modal) + } +} + +private fun previewState() = ReorderAccountsStateUi( + items = listOf( + dummyAccountHolder("Account 1", color = Green), + dummyAccountHolder("Account 2", color = Purple), + dummyFolderHolder("Folder 1", color = Green2Dark), + dummyAccountHolder("Account 3", color = Red2), + dummyAccountHolder("Account 4", color = YellowDark), + dummyFolderHolder("Folder 2", color = Green), + dummyAccountHolder("Account 5", color = Blue), + dummyFolderHolder("Folder 3", color = Green), + ), +) + +private fun dummyAccountHolder(name: String, color: Color) = ReorderAccListItemUi.AccountHolder( + dummyAccountUi(name = name, color = color), +) + +private fun dummyFolderHolder(name: String, color: Color) = ReorderAccListItemUi.FolderHolder( + dummyFolderUi(name = name, color = color) +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/reorder/ReorderAccountsStateUi.kt b/core/ui/src/main/java/com/ivy/core/ui/account/reorder/ReorderAccountsStateUi.kt new file mode 100644 index 0000000..c8bcd80 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/reorder/ReorderAccountsStateUi.kt @@ -0,0 +1,7 @@ +package com.ivy.core.ui.account.reorder + +import com.ivy.core.ui.account.reorder.data.ReorderAccListItemUi + +data class ReorderAccountsStateUi( + val items: List +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/reorder/ReorderAccountsViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/account/reorder/ReorderAccountsViewModel.kt new file mode 100644 index 0000000..40c84ee --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/reorder/ReorderAccountsViewModel.kt @@ -0,0 +1,150 @@ +package com.ivy.core.ui.account.reorder + +import arrow.core.Either +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.domain.FlowViewModel +import com.ivy.core.domain.action.account.WriteAccountsAct +import com.ivy.core.domain.action.account.folder.AccountFoldersFlow +import com.ivy.core.domain.action.account.folder.WriteAccountFolderAct +import com.ivy.core.domain.action.data.AccountListItem +import com.ivy.core.domain.action.data.Modify +import com.ivy.core.ui.account.reorder.data.ReorderAccListItemUi +import com.ivy.core.ui.action.mapping.account.MapAccountUiAct +import com.ivy.core.ui.action.mapping.account.MapFolderUiAct +import com.ivy.data.Sync +import com.ivy.data.SyncState +import com.ivy.data.account.Account +import com.ivy.data.account.AccountFolder +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import com.ivy.core.ui.account.reorder.ReorderAccountsViewModel.InternalState as InternalState1 + +@HiltViewModel +internal class ReorderAccountsViewModel @Inject constructor( + accountFoldersFlow: AccountFoldersFlow, + private val mapAccountUiAct: MapAccountUiAct, + private val mapFolderUiAct: MapFolderUiAct, + private val writeAccountFolderAct: WriteAccountFolderAct, + private val writeAccountsAct: WriteAccountsAct, + private val timeProvider: TimeProvider, +) : FlowViewModel() { + override val initialState = InternalState( + items = emptyList(), + ) + + override val initialUi = ReorderAccountsStateUi( + items = emptyList(), + ) + + override val stateFlow: Flow = accountFoldersFlow(Unit).map { items -> + InternalState( + items = items, + ) + } + + override val uiFlow: Flow = stateFlow + .map { internalState -> + internalState.items.flatMap { + when (it) { + is AccountListItem.AccountHolder -> listOf( + ReorderAccListItemUi.AccountHolder( + account = mapAccountUiAct(it.account) + ) + ) + is AccountListItem.Archived -> it.accounts.toReorderAccListItems() + is AccountListItem.FolderWithAccounts -> listOf( + ReorderAccListItemUi.FolderHolder( + folder = mapFolderUiAct(it.accountFolder) + ) + ) + it.accounts.toReorderAccListItems() + listOf( + ReorderAccListItemUi.FolderEnd + ) + } + } + }.map { items -> + ReorderAccountsStateUi( + items = items, + ) + } + + private suspend fun List.toReorderAccListItems(): List = + this.map { + ReorderAccListItemUi.AccountHolder( + account = mapAccountUiAct(it) + ) + } + + // region Event handling + override suspend fun handleEvent(event: ReorderAccountsEvent) = when (event) { + is ReorderAccountsEvent.Reorder -> handleReorder(event) + } + + private suspend fun handleReorder(event: ReorderAccountsEvent.Reorder) { + val internalItems = state.value.items + val accounts = internalItems.flatMap { + when (it) { + is AccountListItem.AccountHolder -> listOf(it.account) + is AccountListItem.Archived -> it.accounts + is AccountListItem.FolderWithAccounts -> it.accounts + } + } + val folders = internalItems.filterIsInstance() + .map { it.accountFolder } + + val reordered = event.reordered.mapIndexedNotNull { index, item -> + // TODO: Optimize this expensive search logic with a map + when (item) { + is ReorderAccListItemUi.AccountHolder -> + accounts.firstOrNull { it.id.toString() == item.account.id } + ?.copy(orderNum = index.toDouble()) + ?.let { Either.Left(it) } + is ReorderAccListItemUi.FolderHolder -> + folders.firstOrNull { it.id == item.folder.id } + ?.copy(orderNum = index.toDouble()) + ?.let { Either.Right(it) } + ReorderAccListItemUi.FolderEnd -> null + } + } + + val expectedCount = uiState.value.items.count { + when (it) { + is ReorderAccListItemUi.AccountHolder -> true + is ReorderAccListItemUi.FolderHolder -> true + ReorderAccListItemUi.FolderEnd -> false + } + } + // verify no lost of data + if (reordered.size == expectedCount) { + val accountsToUpdate = reordered.filterIsInstance>() + .map { it.value } + .map { + it.copy( + sync = Sync( + state = SyncState.Syncing, + lastUpdated = timeProvider.timeNow(), + ) + ) + } + writeAccountsAct(Modify.saveMany(accountsToUpdate)) + val foldersToUpdate = reordered.filterIsInstance>() + .map { it.value } + .map { + it.copy( + sync = Sync( + state = SyncState.Syncing, + lastUpdated = timeProvider.timeNow(), + ) + ) + } + writeAccountFolderAct(Modify.saveMany(foldersToUpdate)) + } + } + + // endregion + + data class InternalState( + val items: List, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/account/reorder/data/ReorderAccListItemUi.kt b/core/ui/src/main/java/com/ivy/core/ui/account/reorder/data/ReorderAccListItemUi.kt new file mode 100644 index 0000000..c27cc61 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/account/reorder/data/ReorderAccListItemUi.kt @@ -0,0 +1,17 @@ +package com.ivy.core.ui.account.reorder.data + +import androidx.compose.runtime.Immutable +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.account.FolderUi + +@Immutable +sealed interface ReorderAccListItemUi { + @Immutable + data class AccountHolder(val account: AccountUi) : ReorderAccListItemUi + + @Immutable + data class FolderHolder(val folder: FolderUi) : ReorderAccListItemUi + + @Immutable + object FolderEnd : ReorderAccListItemUi +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/action/AccountsUiFlow.kt b/core/ui/src/main/java/com/ivy/core/ui/action/AccountsUiFlow.kt new file mode 100644 index 0000000..0ce6706 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/action/AccountsUiFlow.kt @@ -0,0 +1,38 @@ +package com.ivy.core.ui.action + +import android.content.Context +import com.ivy.core.domain.action.SharedFlowAction +import com.ivy.core.domain.action.account.AccountsFlow +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.design.l0_system.color.toComposeColor +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AccountsUiFlow @Inject constructor( + @ApplicationContext + private val appContext: Context, + private val accountsFlow: AccountsFlow, +) : SharedFlowAction?>() { + override fun initialValue(): Map? = null + + override fun createFlow(): Flow?> = accountsFlow().map { accs -> + accs.associate { + val id = it.id.toString() + id to AccountUi( + id = id, + name = it.name, + color = it.color.toComposeColor(), + icon = itemIcon( + appContext = appContext, + iconId = it.icon, + defaultTo = DefaultTo.Account, + ), + excluded = it.excluded, + ) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/action/CategoriesUiFlow.kt b/core/ui/src/main/java/com/ivy/core/ui/action/CategoriesUiFlow.kt new file mode 100644 index 0000000..a5e7756 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/action/CategoriesUiFlow.kt @@ -0,0 +1,38 @@ +package com.ivy.core.ui.action + +import android.content.Context +import com.ivy.core.domain.action.SharedFlowAction +import com.ivy.core.domain.action.category.CategoriesFlow +import com.ivy.core.ui.data.CategoryUi +import com.ivy.design.l0_system.color.toComposeColor +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CategoriesUiFlow @Inject constructor( + @ApplicationContext + private val appContext: Context, + private val categoriesFlow: CategoriesFlow, +) : SharedFlowAction?>() { + override fun initialValue(): Map? = null + + override fun createFlow(): Flow?> = categoriesFlow().map { cats -> + cats.associate { + val id = it.id.toString() + id to CategoryUi( + id = id, + name = it.name, + color = it.color.toComposeColor(), + icon = itemIcon( + appContext = appContext, + iconId = it.icon, + defaultTo = DefaultTo.Account, + ), + hasParent = it.parentCategoryId != null + ) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/action/ExchangeInBaseCurrencyFlow.kt b/core/ui/src/main/java/com/ivy/core/ui/action/ExchangeInBaseCurrencyFlow.kt new file mode 100644 index 0000000..2ff85f6 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/action/ExchangeInBaseCurrencyFlow.kt @@ -0,0 +1,35 @@ +package com.ivy.core.ui.action + +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.domain.action.exchange.ExchangeFlow +import com.ivy.core.domain.action.settings.basecurrency.BaseCurrencyFlow +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.format +import com.ivy.data.Value +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class ExchangeInBaseCurrencyFlow @Inject constructor( + private val baseCurrencyFlow: BaseCurrencyFlow, + private val exchangeFlow: ExchangeFlow, +) : FlowAction() { + @OptIn(ExperimentalCoroutinesApi::class) + override fun createFlow(input: Value): Flow = + baseCurrencyFlow().map { baseCurrency -> + if (input.currency != baseCurrency) { + exchangeFlow(ExchangeFlow.Input(input, baseCurrency)) + } else { + flowOf(null) + } + }.flatMapLatest { flow -> + flow.map { value -> + value?.let { + format(it, shortenFiat = true) + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/action/ItemIconAct.kt b/core/ui/src/main/java/com/ivy/core/ui/action/ItemIconAct.kt new file mode 100644 index 0000000..591d6d0 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/action/ItemIconAct.kt @@ -0,0 +1,66 @@ +package com.ivy.core.ui.action + +import android.content.Context +import com.ivy.core.domain.action.Action +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.data.ItemIconId +import com.ivy.resources.R +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class ItemIconAct @Inject constructor( + @ApplicationContext + private val appContext: Context, +) : Action() { + data class Input( + val iconId: ItemIconId?, + val defaultTo: DefaultTo + ) + + override suspend fun action(input: Input): ItemIcon { + return itemIcon( + appContext = appContext, + iconId = input.iconId, + defaultTo = input.defaultTo, + ) + } +} + +fun itemIcon( + appContext: Context, + iconId: ItemIconId?, + defaultTo: DefaultTo, +): ItemIcon { + fun default(): ItemIcon = when (defaultTo) { + DefaultTo.Folder -> ItemIcon.Unknown( + icon = R.drawable.ic_vue_files_folder, + iconId = "ic_vue_files_folder", + ) + else -> ItemIcon.Sized( + iconS = when (defaultTo) { + DefaultTo.Account -> R.drawable.ic_custom_account_s + DefaultTo.Category -> R.drawable.ic_custom_category_s + else -> error("not expected size icon") + }, + iconM = when (defaultTo) { + DefaultTo.Account -> R.drawable.ic_custom_account_m + DefaultTo.Category -> R.drawable.ic_custom_category_m + else -> error("not expected size icon") + }, + iconL = when (defaultTo) { + DefaultTo.Account -> R.drawable.ic_custom_account_l + DefaultTo.Category -> R.drawable.ic_custom_category_l + else -> error("not expected size icon") + }, + iconId = iconId + ) + } + + return iconId?.let { itemIconOptional(appContext, iconId) } ?: default() +} + +enum class DefaultTo { + Account, + Category, + Folder +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/action/ItemIconOptionalAct.kt b/core/ui/src/main/java/com/ivy/core/ui/action/ItemIconOptionalAct.kt new file mode 100644 index 0000000..cd46f40 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/action/ItemIconOptionalAct.kt @@ -0,0 +1,88 @@ +package com.ivy.core.ui.action + +import android.annotation.SuppressLint +import android.content.Context +import androidx.annotation.DrawableRes +import com.ivy.core.domain.action.Action +import com.ivy.core.ui.data.icon.IconSize +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.data.ItemIconId +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class ItemIconOptionalAct @Inject constructor( + @ApplicationContext + private val appContext: Context +) : Action() { + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(iconId: ItemIconId): ItemIcon? { + return itemIconOptional(appContext, iconId) + } +} + +fun itemIconOptional( + appContext: Context, + iconId: ItemIconId +): ItemIcon? { + fun unknown(): ItemIcon? = + getIcon(appContext = appContext, iconId = iconId)?.let { iconRes -> + ItemIcon.Unknown( + icon = iconRes, + iconId = iconId, + ) + } + + val iconS = getSizedIcon(appContext, iconId = iconId, size = IconSize.S) ?: return unknown() + val iconM = getSizedIcon(appContext, iconId = iconId, size = IconSize.M) ?: return unknown() + val iconL = getSizedIcon(appContext, iconId = iconId, size = IconSize.L) ?: return unknown() + + return ItemIcon.Sized( + iconS = iconS, + iconM = iconM, + iconL = iconL, + iconId = iconId, + ) +} + +@DrawableRes +fun getSizedIcon( + appContext: Context, + iconId: ItemIconId?, + size: IconSize, +): Int? = iconId?.let { + getDrawableByName( + appContext = appContext, + fileName = "ic_custom_${normalize(iconId)}_${size.value}" + ) +} + +@DrawableRes +private fun getIcon( + appContext: Context, + iconId: ItemIconId? +): Int? = iconId?.let { + getDrawableByName( + appContext = appContext, + fileName = normalize(iconId) + ) +} + +@SuppressLint("DiscouragedApi") +@DrawableRes +private fun getDrawableByName( + appContext: Context, + fileName: String +): Int? = try { + appContext.resources.getIdentifier( + fileName, + "drawable", + appContext.packageName + ).takeIf { it != 0 } +} catch (e: Exception) { + null +} + +private fun normalize(iconId: ItemIconId): String = iconId + .replace(" ", "") + .trim() + .lowercase() \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/action/mapping/MapCategoryUiAct.kt b/core/ui/src/main/java/com/ivy/core/ui/action/mapping/MapCategoryUiAct.kt new file mode 100644 index 0000000..bcab84c --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/action/mapping/MapCategoryUiAct.kt @@ -0,0 +1,20 @@ +package com.ivy.core.ui.action.mapping + +import com.ivy.core.ui.action.DefaultTo +import com.ivy.core.ui.action.ItemIconAct +import com.ivy.core.ui.data.CategoryUi +import com.ivy.data.category.Category +import com.ivy.design.l0_system.color.toComposeColor +import javax.inject.Inject + +class MapCategoryUiAct @Inject constructor( + private val itemIconAct: ItemIconAct +) : MapUiAction() { + override suspend fun transform(domain: Category): CategoryUi = CategoryUi( + id = domain.id.toString(), + name = domain.name, + icon = itemIconAct(ItemIconAct.Input(iconId = domain.icon, defaultTo = DefaultTo.Category)), + color = domain.color.toComposeColor(), + hasParent = domain.parentCategoryId != null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/action/mapping/MapSelectedPeriodUiAct.kt b/core/ui/src/main/java/com/ivy/core/ui/action/mapping/MapSelectedPeriodUiAct.kt new file mode 100644 index 0000000..fe5064d --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/action/mapping/MapSelectedPeriodUiAct.kt @@ -0,0 +1,99 @@ +package com.ivy.core.ui.action.mapping + +import android.content.Context +import androidx.annotation.StringRes +import com.ivy.common.time.dateNowLocal +import com.ivy.common.time.format +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.ui.R +import com.ivy.core.ui.data.period.MonthUi +import com.ivy.core.ui.data.period.SelectedPeriodUi +import com.ivy.core.ui.data.period.TimeRangeUi +import com.ivy.core.ui.data.period.fullMonthName +import com.ivy.data.time.SelectedPeriod +import com.ivy.data.time.TimeRange +import com.ivy.data.time.TimeUnit +import dagger.hilt.android.qualifiers.ApplicationContext +import java.time.LocalDateTime +import javax.inject.Inject + +class MapSelectedPeriodUiAct @Inject constructor( + @ApplicationContext + private val appContext: Context, + private val timeProvider: TimeProvider, +) : MapUiAction() { + override suspend fun transform(domain: SelectedPeriod): SelectedPeriodUi = when (domain) { + is SelectedPeriod.AllTime -> SelectedPeriodUi.AllTime( + periodBtnText = appContext.getString(R.string.all_time), + rangeUi = rangeUi(domain) + ) + is SelectedPeriod.CustomRange -> SelectedPeriodUi.CustomRange( + periodBtnText = formatFromToPeriod(domain.range), + rangeUi = rangeUi(domain) + ) + is SelectedPeriod.InTheLast -> SelectedPeriodUi.InTheLast( + periodBtnText = formatInTheLast(domain), + n = domain.n, + unit = domain.unit, + rangeUi = rangeUi(domain) + ) + is SelectedPeriod.Monthly -> SelectedPeriodUi.Monthly( + periodBtnText = formatMonthly(domain), + month = MonthUi( + number = domain.month.number, + year = domain.month.year, + currentYear = domain.month.year == dateNowLocal().year, + fullName = fullMonthName(appContext, monthNumber = domain.month.number), + ), + rangeUi = rangeUi(domain) + ) + } + + private fun formatInTheLast(period: SelectedPeriod.InTheLast): String { + fun unit(@StringRes one: Int, @StringRes many: Int) = + if (period.n == 1) appContext.getString(one) else appContext.getString(many) + + // TODO: Re-work using String resource plurals + val unit = when (period.unit) { + TimeUnit.Day -> unit(one = R.string.day, many = R.string.days) + TimeUnit.Week -> unit(one = R.string.week, many = R.string.weeks) + TimeUnit.Month -> unit(one = R.string.month, many = R.string.months) + TimeUnit.Year -> unit(one = R.string.year, many = R.string.years) + } + return "Last ${period.n} $unit" + } + + private fun formatMonthly(monthly: SelectedPeriod.Monthly): String = + if (monthly.startDayOfMonth != 1) { + formatFromToPeriod(monthly.range) + } else { + val month = monthly.range.from + val thisYear = month.year == dateNowLocal().year + val pattern = if (thisYear) "MMMM" else "MMMM. yyyy" + month.format(pattern) + } + + private fun formatFromToPeriod(range: TimeRange): String { + fun format(time: LocalDateTime, currentYear: Int): String = + time.format(if (time.year == currentYear) "MMM dd" else "MMM dd, yyyy") + + val currentYear = timeProvider.dateNow().year + val from = format(range.from, currentYear) + val to = format(range.to, currentYear) + return "$from - $to" + } + + private fun rangeUi(selectedPeriod: SelectedPeriod): TimeRangeUi { + fun format(time: LocalDateTime, currentYear: Int): String = + time.format(if (time.year == currentYear) "MMM. dd" else "MMM. dd, yyyy") + + val period = selectedPeriod.range + val currentYear = dateNowLocal().year + + return TimeRangeUi( + range = period, + fromText = format(period.from, currentYear), + toText = format(period.to, currentYear) + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/action/mapping/MapUiAction.kt b/core/ui/src/main/java/com/ivy/core/ui/action/mapping/MapUiAction.kt new file mode 100644 index 0000000..9310adc --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/action/mapping/MapUiAction.kt @@ -0,0 +1,14 @@ +package com.ivy.core.ui.action.mapping + +import com.ivy.core.domain.action.Action +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +abstract class MapUiAction : Action() { + + abstract suspend fun transform(domain: Domain): Ui + + override suspend fun action(input: Domain): Ui = withContext(Dispatchers.Default) { + transform(input) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/action/mapping/account/MapAccountUiAct.kt b/core/ui/src/main/java/com/ivy/core/ui/action/mapping/account/MapAccountUiAct.kt new file mode 100644 index 0000000..fc74d37 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/action/mapping/account/MapAccountUiAct.kt @@ -0,0 +1,21 @@ +package com.ivy.core.ui.action.mapping.account + +import com.ivy.core.ui.action.DefaultTo +import com.ivy.core.ui.action.ItemIconAct +import com.ivy.core.ui.action.mapping.MapUiAction +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.data.account.Account +import com.ivy.design.l0_system.color.toComposeColor +import javax.inject.Inject + +class MapAccountUiAct @Inject constructor( + private val itemIconAct: ItemIconAct +) : MapUiAction() { + override suspend fun transform(domain: Account): AccountUi = AccountUi( + id = domain.id.toString(), + name = domain.name, + icon = itemIconAct(ItemIconAct.Input(iconId = domain.icon, defaultTo = DefaultTo.Account)), + color = domain.color.toComposeColor(), + excluded = domain.excluded, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/action/mapping/account/MapFolderUiAct.kt b/core/ui/src/main/java/com/ivy/core/ui/action/mapping/account/MapFolderUiAct.kt new file mode 100644 index 0000000..f3d1eb6 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/action/mapping/account/MapFolderUiAct.kt @@ -0,0 +1,21 @@ +package com.ivy.core.ui.action.mapping.account + +import com.ivy.core.ui.action.DefaultTo +import com.ivy.core.ui.action.ItemIconAct +import com.ivy.core.ui.action.mapping.MapUiAction +import com.ivy.core.ui.data.account.FolderUi +import com.ivy.data.account.AccountFolder +import com.ivy.design.l0_system.color.toComposeColor +import javax.inject.Inject + +class MapFolderUiAct @Inject constructor( + private val itemIconAct: ItemIconAct, +) : MapUiAction() { + override suspend fun transform(domain: AccountFolder) = FolderUi( + id = domain.id, + name = domain.name, + icon = itemIconAct(ItemIconAct.Input(domain.icon, DefaultTo.Folder)), + color = domain.color.toComposeColor(), + orderNum = domain.orderNum, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/action/mapping/trn/MapTrnTimeUiAct.kt b/core/ui/src/main/java/com/ivy/core/ui/action/mapping/trn/MapTrnTimeUiAct.kt new file mode 100644 index 0000000..83e8c87 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/action/mapping/trn/MapTrnTimeUiAct.kt @@ -0,0 +1,44 @@ +package com.ivy.core.ui.action.mapping.trn + +import android.content.Context +import com.ivy.common.time.deviceFormat +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.ui.R +import com.ivy.core.ui.action.mapping.MapUiAction +import com.ivy.core.ui.data.transaction.TrnTimeUi +import com.ivy.core.ui.time.formatNicely +import com.ivy.data.transaction.TrnTime +import dagger.hilt.android.qualifiers.ApplicationContext +import java.time.LocalDateTime +import javax.inject.Inject + +class MapTrnTimeUiAct @Inject constructor( + @ApplicationContext + private val appContext: Context, + private val timeProvider: TimeProvider +) : MapUiAction() { + override suspend fun transform(domain: TrnTime): TrnTimeUi = mapTrnTimeUi(domain) + + private fun mapTrnTimeUi(domain: TrnTime): TrnTimeUi { + fun formatDateTime(time: LocalDateTime): String = + time.formatNicely( + context = appContext, + timeProvider = timeProvider, + includeWeekDay = true + ) + + return when (domain) { + is TrnTime.Actual -> TrnTimeUi.Actual( + actualDate = formatDateTime(domain.actual).uppercase(), + actualTime = domain.actual.toLocalTime().deviceFormat(appContext), + ) + is TrnTime.Due -> TrnTimeUi.Due( + dueOnDate = appContext.getString( + R.string.due_on, formatDateTime(domain.due) + ).uppercase(), + dueOnTime = domain.due.toLocalTime().deviceFormat(appContext), + upcoming = timeProvider.timeNow().isBefore(domain.due) + ) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/CalcHistoryTrnsToRawStats.kt b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/CalcHistoryTrnsToRawStats.kt new file mode 100644 index 0000000..8b12064 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/CalcHistoryTrnsToRawStats.kt @@ -0,0 +1,33 @@ +package com.ivy.core.ui.algorithm.trnhistory + +import com.ivy.core.domain.algorithm.calc.data.RawStats +import com.ivy.core.domain.algorithm.calc.rawStats +import com.ivy.core.persistence.algorithm.calc.CalcTrn +import com.ivy.core.persistence.algorithm.trnhistory.CalcHistoryTrnView +import com.ivy.core.persistence.entity.trn.data.TrnTimeType +import com.ivy.data.transaction.TrnPurpose + +fun calcHistoryTrnsToRawStats( + calcHistoryTrns: List, +): RawStats { + fun mustBeExcluded(purpose: TrnPurpose?): Boolean = when (purpose) { + TrnPurpose.TransferFrom, TrnPurpose.TransferTo -> true + TrnPurpose.Fee, TrnPurpose.AdjustBalance, null -> false + } + + val calcTrns = calcHistoryTrns.mapNotNull { + // Hidden (state = TrnStatHidde) transactions are excluded on DB level + if (it.timeType == TrnTimeType.Due || mustBeExcluded(it.purpose)) { + null + } else it.toCalcTrn() + } + return rawStats(calcTrns) +} + +// TODO: Wasting some memory, investigate! +fun CalcHistoryTrnView.toCalcTrn() = CalcTrn( + amount = amount, + currency = currency, + type = type, + time = time +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/PeriodDataFlow.kt b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/PeriodDataFlow.kt new file mode 100644 index 0000000..7131e1f --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/PeriodDataFlow.kt @@ -0,0 +1,132 @@ +package com.ivy.core.ui.algorithm.trnhistory + +import android.content.Context +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.toUtc +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.domain.action.period.SelectedPeriodFlow +import com.ivy.core.domain.algorithm.calc.RatesFlow +import com.ivy.core.domain.algorithm.calc.exchangeRawStats +import com.ivy.core.domain.algorithm.trnhistory.CollapsedTrnListKeysFlow +import com.ivy.core.domain.pure.format.format +import com.ivy.core.persistence.IvyWalletCoreDb +import com.ivy.core.persistence.algorithm.trnhistory.CalcHistoryTrnView +import com.ivy.core.ui.action.AccountsUiFlow +import com.ivy.core.ui.action.CategoriesUiFlow +import com.ivy.core.ui.algorithm.trnhistory.data.PeriodDataUi +import com.ivy.core.ui.algorithm.trnhistory.data.TrnListItemUi +import com.ivy.core.ui.algorithm.trnhistory.data.raw.RawDateDivider +import com.ivy.core.ui.algorithm.trnhistory.data.raw.RawDividerType +import com.ivy.core.ui.algorithm.trnhistory.data.raw.RawDueDivider +import com.ivy.core.ui.algorithm.trnhistory.data.raw.TrnListRawSectionKey +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import java.time.Instant +import java.time.ZoneOffset +import javax.inject.Inject + + +/** + * @return Selected period's data: + * - Income/Expense in base currency + * - Transactions list: upcoming, overdue, history _(grouped properly)_ + */ +class PeriodDataFlow @Inject constructor( + @ApplicationContext + private val appContext: Context, + private val selectedPeriodFlow: SelectedPeriodFlow, + private val db: IvyWalletCoreDb, + private val timeProvider: TimeProvider, + private val accountsUiFlow: AccountsUiFlow, + private val categoriesUiFlow: CategoriesUiFlow, + private val collapsedTrnListKeysFlow: CollapsedTrnListKeysFlow, + private val ratesFlow: RatesFlow, +) : FlowAction() { + sealed interface Input { + object All : Input + // TODO: Add by Category, by Account, etc + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun createFlow(input: Input): Flow = + selectedPeriodFlow().flatMapLatest { period -> + when (input) { + Input.All -> db.calcHistoryTrnDao().findAllInPeriod( + from = period.range.from.toUtc(timeProvider), + to = period.range.to.toUtc(timeProvider) + ) + } + }.flatMapLatest { calHistoryTrns -> + // TODO: Investigate memory issues! + combine( + flowOf(calcHistoryTrnsToRawStats(calHistoryTrns)), + rawTrnListMapFlow(calHistoryTrns) + ) { periodRawStats, rawTrnListMap -> + periodRawStats to rawTrnListMap + } + }.flatMapLatest { (periodRawStats, rawTrnListMap) -> + combine( + ratesFlow(), + collapsedTrnListKeysFlow() + ) { rates, collapsedKeys -> + val periodStats = exchangeRawStats(periodRawStats, rates, rates.baseCurrency) + + val today = timeProvider.dateNow() + val items = rawTrnListMap.mapNotNull { (key, _) -> + key to (key.id in collapsedKeys) + }.sortedByDescending { (key, _) -> + when (key) { + is RawDueDivider -> when (key.type) { + RawDividerType.Upcoming -> Instant.MAX.epochSecond + RawDividerType.Overdue -> Instant.MAX + .minusSeconds(10).epochSecond + } + is RawDateDivider -> key.date.atStartOfDay() + .toEpochSecond(ZoneOffset.UTC) + } + }.flatMap { (key, collapsed) -> + val divider = when (key) { + is RawDueDivider -> toDueDividerUi( + raw = key, + collapsed = collapsed, + rates = rates, + getString = appContext::getString, + ) + is RawDateDivider -> toDateDividerUi( + appContext = appContext, + raw = key, + collapsed = collapsed, + rates = rates, + today = today + ) + } + + if (collapsed) listOf(divider) else listOf(divider) + rawTrnListMap[key]!! + } + + PeriodDataUi( + periodIncome = format(periodStats.income, shortenFiat = true), + periodExpense = format(periodStats.expense, shortenFiat = true), + items = items + ) + } + } + + private fun rawTrnListMapFlow( + calcHistoryTrns: List, + ): Flow>> = combine( + accountsUiFlow(), + categoriesUiFlow() + ) { accounts, categories -> + if (accounts != null && categories != null) { + rawTrnListMap( + appContext = appContext, + calcHistoryTrns = calcHistoryTrns, + accounts = accounts, + categories = categories, + timeProvider = timeProvider + ) + } else emptyMap() + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/RawTrnListMap.kt b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/RawTrnListMap.kt new file mode 100644 index 0000000..bd7c2b9 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/RawTrnListMap.kt @@ -0,0 +1,134 @@ +package com.ivy.core.ui.algorithm.trnhistory + +import android.content.Context +import com.ivy.common.time.dateId +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.toLocal +import com.ivy.common.time.toUtc +import com.ivy.core.domain.algorithm.calc.rawStats +import com.ivy.core.domain.algorithm.trnhistory.OverdueSectionKey +import com.ivy.core.domain.algorithm.trnhistory.UpcomingSectionKey +import com.ivy.core.persistence.algorithm.trnhistory.CalcHistoryTrnView +import com.ivy.core.persistence.entity.trn.data.TrnTimeType +import com.ivy.core.ui.algorithm.trnhistory.data.TrnListItemUi +import com.ivy.core.ui.algorithm.trnhistory.data.raw.RawDateDivider +import com.ivy.core.ui.algorithm.trnhistory.data.raw.RawDividerType +import com.ivy.core.ui.algorithm.trnhistory.data.raw.RawDueDivider +import com.ivy.core.ui.algorithm.trnhistory.data.raw.TrnListRawSectionKey +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.data.transaction.TrnPurpose +import java.time.LocalDate +import java.time.LocalDateTime + +/** + * What it does: + * - groups trns into 1) date sections 2) upcoming section 3) overdue section + * - calculates raw stats for the date, upcoming and overdue sections + * - transforms calcHistoryTrn into TransactionUi + * - batches calcHistoryTrns together into TransferUi + */ +fun rawTrnListMap( + appContext: Context, + calcHistoryTrns: List, + accounts: Map, + categories: Map, + timeProvider: TimeProvider, +): Map> { + val result = mutableMapOf>() + + val timeNow = timeProvider.timeNow() + val timeNowInstant = timeNow.toUtc(timeProvider) + + calcHistoryTrns.groupBy { + when (it.timeType) { + TrnTimeType.Actual -> it.time.toLocal(timeProvider).toLocalDate() + TrnTimeType.Due -> if (it.time > timeNowInstant) + UpcomingSectionKey else OverdueSectionKey + } + }.forEach { (key, trns) -> + val rawStats = rawStats(trns.mapNotNull { + when (it.purpose) { + TrnPurpose.TransferFrom, TrnPurpose.TransferTo -> null + TrnPurpose.Fee, TrnPurpose.AdjustBalance, null -> it.toCalcTrn() + } + }) + val sortedTrnsUi = parseSortedTrnListItemsUi( + appContext = appContext, + trns = trns, + accounts = accounts, + categories = categories, + timeProvider = timeProvider, + timeNow = timeNow, + ) + val rawKey = when (key) { + UpcomingSectionKey -> { + RawDueDivider( + id = UpcomingSectionKey, + type = RawDividerType.Upcoming, + rawStats = rawStats, + ) + } + OverdueSectionKey -> { + RawDueDivider( + id = OverdueSectionKey, + type = RawDividerType.Overdue, + rawStats = rawStats, + ) + } + else -> { + // History date + val date = key as LocalDate + RawDateDivider( + id = date.dateId(), + date = date, + cashflow = rawStats, + ) + } + } + result[rawKey] = sortedTrnsUi + } + + return result +} + +fun parseSortedTrnListItemsUi( + appContext: Context, + trns: List, + accounts: Map, + categories: Map, + timeProvider: TimeProvider, + timeNow: LocalDateTime, +): List { + return trns + // TODO: Investigate if performance can be improved + .groupBy { it.batchId } + .mapNotNull { (batchId, trns) -> + if (batchId != null) { + // It's a single transfer! + parseTransfer( + appContext = appContext, + batchId = batchId, + batch = trns, + accounts = accounts, + categories = categories, + timeProvider = timeProvider, + timeNow = timeNow, + )?.let { listOf(it to trns.first().time) } + } else { + // All other transactions (batchId = null) + trns.mapNotNull { trn -> + parseTransactionUi( + appContext = appContext, + trn = trn, + accounts = accounts, + categories = categories, + timeProvider = timeProvider, + timeNow = timeNow + )?.let { it to trn.time } + }.takeIf { it.isNotEmpty() } + } + }.flatten() + .sortedByDescending { (_, time) -> time } + .map { it.first } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/ToDateDividerUi.kt b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/ToDateDividerUi.kt new file mode 100644 index 0000000..f861007 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/ToDateDividerUi.kt @@ -0,0 +1,48 @@ +package com.ivy.core.ui.algorithm.trnhistory + +import android.content.Context +import com.ivy.common.time.format +import com.ivy.core.domain.algorithm.calc.exchangeRawStats +import com.ivy.core.domain.pure.format.SignedValueUi +import com.ivy.core.domain.pure.format.format +import com.ivy.core.ui.R +import com.ivy.core.ui.algorithm.trnhistory.data.DateDividerUi +import com.ivy.core.ui.algorithm.trnhistory.data.raw.RawDateDivider +import com.ivy.data.Value +import com.ivy.data.exchange.ExchangeRates +import java.time.LocalDate +import kotlin.math.absoluteValue + +suspend fun toDateDividerUi( + appContext: Context, + raw: RawDateDivider, + collapsed: Boolean, + rates: ExchangeRates, + today: LocalDate, +): DateDividerUi { + val stats = exchangeRawStats(raw.cashflow, rates, rates.baseCurrency) + val cashFlowAmount = stats.income.amount - stats.expense.amount + + val cashflowUi = format( + Value(cashFlowAmount.absoluteValue, rates.baseCurrency), + shortenFiat = true + ) + return DateDividerUi( + id = raw.id, + date = raw.date.format( + if (today.year == raw.date.year) "MMMM dd." else "MMM dd, yyyy" + ), + dateContext = when (raw.date) { + today -> appContext.getString(R.string.today) + today.minusDays(1) -> appContext.getString(R.string.yesterday) + today.plusDays(1) -> appContext.getString(R.string.tomorrow) + else -> null + } ?: raw.date.format("EEEE"), + cashflow = when { + cashFlowAmount > 0 -> SignedValueUi.Positive(cashflowUi) + cashFlowAmount < 0 -> SignedValueUi.Negative(cashflowUi) + else -> SignedValueUi.Zero(cashflowUi) + }, + collapsed = collapsed, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/ToDueDividerUi.kt b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/ToDueDividerUi.kt new file mode 100644 index 0000000..005eb34 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/ToDueDividerUi.kt @@ -0,0 +1,42 @@ +package com.ivy.core.ui.algorithm.trnhistory + +import com.ivy.core.domain.algorithm.calc.exchangeRawStats +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.format +import com.ivy.core.ui.R +import com.ivy.core.ui.algorithm.trnhistory.data.DueDividerUi +import com.ivy.core.ui.algorithm.trnhistory.data.DueDividerUiType +import com.ivy.core.ui.algorithm.trnhistory.data.raw.RawDividerType +import com.ivy.core.ui.algorithm.trnhistory.data.raw.RawDueDivider +import com.ivy.data.Value +import com.ivy.data.exchange.ExchangeRates + +suspend fun toDueDividerUi( + raw: RawDueDivider, + collapsed: Boolean, + rates: ExchangeRates, + getString: (Int) -> String +): DueDividerUi { + fun dueValueUi(value: Value): ValueUi? = value.takeIf { it.amount > 0 }?.let { + format(it, shortenFiat = true) + } + + val stats = exchangeRawStats(raw.rawStats, rates, rates.baseCurrency) + + return DueDividerUi( + id = raw.id, + income = dueValueUi(stats.income), + expense = dueValueUi(stats.expense), + label = getString( + when (raw.type) { + RawDividerType.Upcoming -> R.string.upcoming + RawDividerType.Overdue -> R.string.overdue + } + ), + type = when (raw.type) { + RawDividerType.Upcoming -> DueDividerUiType.Upcoming + RawDividerType.Overdue -> DueDividerUiType.Overdue + }, + collapsed = collapsed, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/ToTransactionUi.kt b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/ToTransactionUi.kt new file mode 100644 index 0000000..3c6ded3 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/ToTransactionUi.kt @@ -0,0 +1,77 @@ +package com.ivy.core.ui.algorithm.trnhistory + +import android.content.Context +import com.ivy.common.time.deviceFormat +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.toLocal +import com.ivy.core.domain.pure.format.format +import com.ivy.core.persistence.algorithm.trnhistory.CalcHistoryTrnView +import com.ivy.core.persistence.entity.trn.data.TrnTimeType +import com.ivy.core.ui.R +import com.ivy.core.ui.algorithm.trnhistory.data.TransactionUi +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.transaction.TrnTimeUi +import com.ivy.core.ui.time.formatNicely +import com.ivy.data.Value +import java.time.Instant +import java.time.LocalDateTime + + +fun parseTransactionUi( + appContext: Context, + trn: CalcHistoryTrnView, + accounts: Map, + categories: Map, + timeProvider: TimeProvider, + timeNow: LocalDateTime, +): TransactionUi? { + return TransactionUi( + id = trn.id, + value = format(Value(trn.amount, trn.currency), shortenFiat = false), + type = trn.type, + time = toTrnTimeUi( + appContext = appContext, + time = trn.time, + timeType = trn.timeType, + timeProvider = timeProvider, + timeNow = timeNow, + ), + account = accounts[trn.accountId] ?: return null, + category = trn.categoryId?.let { categories[it] }, + title = trn.title, + description = trn.description, + ) +} + +fun toTrnTimeUi( + appContext: Context, + time: Instant, + timeType: TrnTimeType, + timeProvider: TimeProvider, + timeNow: LocalDateTime, // used for optimization purposes +): TrnTimeUi { + fun formatDateTime( + time: LocalDateTime, + ): String = time.formatNicely( + context = appContext, + timeProvider = timeProvider, + includeWeekDay = true + ) + + val trnDateTime = time.toLocal(timeProvider) + + return when (timeType) { + TrnTimeType.Actual -> TrnTimeUi.Actual( + actualDate = formatDateTime(trnDateTime).uppercase(), + actualTime = trnDateTime.toLocalTime().deviceFormat(appContext), + ) + TrnTimeType.Due -> TrnTimeUi.Due( + dueOnDate = appContext.getString( + R.string.due_on, formatDateTime(trnDateTime) + ).uppercase(), + dueOnTime = trnDateTime.toLocalTime().deviceFormat(appContext), + upcoming = timeNow.isBefore(trnDateTime) + ) + } +} diff --git a/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/ToTransferUi.kt b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/ToTransferUi.kt new file mode 100644 index 0000000..7762295 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/ToTransferUi.kt @@ -0,0 +1,45 @@ +package com.ivy.core.ui.algorithm.trnhistory + +import android.content.Context +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.domain.pure.format.format +import com.ivy.core.persistence.algorithm.trnhistory.CalcHistoryTrnView +import com.ivy.core.ui.algorithm.trnhistory.data.TransferUi +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.data.Value +import com.ivy.data.transaction.TrnPurpose +import java.time.LocalDateTime + +fun parseTransfer( + appContext: Context, + batchId: String, + batch: List, + accounts: Map, + categories: Map, + timeProvider: TimeProvider, + timeNow: LocalDateTime, +): TransferUi? { + val from = batch.firstOrNull { it.purpose == TrnPurpose.TransferFrom } ?: return null + val to = batch.firstOrNull { it.purpose == TrnPurpose.TransferTo } ?: return null + val fee = batch.firstOrNull { it.purpose == TrnPurpose.Fee } + + return TransferUi( + batchId = batchId, + fromAmount = format(Value(from.amount, from.currency), shortenFiat = false), + fromAccount = accounts[from.accountId] ?: return null, + toAmount = format(Value(to.amount, to.currency), shortenFiat = false), + toAccount = accounts[to.accountId] ?: return null, + fee = fee?.let { format(Value(it.amount, it.currency), shortenFiat = false) }, + time = toTrnTimeUi( + appContext = appContext, + time = from.time, + timeType = from.timeType, + timeProvider = timeProvider, + timeNow = timeNow, + ), + category = from.categoryId?.let { categories[it] }, + title = from.title, + description = from.description, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/DateDividerUi.kt b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/DateDividerUi.kt new file mode 100644 index 0000000..675afa7 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/DateDividerUi.kt @@ -0,0 +1,13 @@ +package com.ivy.core.ui.algorithm.trnhistory.data + +import androidx.compose.runtime.Immutable +import com.ivy.core.domain.pure.format.SignedValueUi + +@Immutable +data class DateDividerUi( + val id: String, // a unique string used for collapse/expanded purposes + val date: String, + val dateContext: String, + val cashflow: SignedValueUi, + val collapsed: Boolean +) : TrnListItemUi \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/DueDividerUi.kt b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/DueDividerUi.kt new file mode 100644 index 0000000..c07a8bb --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/DueDividerUi.kt @@ -0,0 +1,33 @@ +package com.ivy.core.ui.algorithm.trnhistory.data + +import androidx.compose.runtime.Immutable +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.dummyValueUi +import java.util.* + +@Immutable +data class DueDividerUi( + val id: String, // a unique string used for collapse/expanded purposes + val income: ValueUi?, + val expense: ValueUi?, + val label: String, + val type: DueDividerUiType, + val collapsed: Boolean +) : TrnListItemUi + +@Immutable +enum class DueDividerUiType { + Upcoming, Overdue +} + +fun dummyDueDividerUi( + id: String = UUID.randomUUID() + .toString(), // a unique string used for collapse/expanded purposes + income: ValueUi? = dummyValueUi(), + expense: ValueUi? = dummyValueUi(), + label: String = "Upcoming", + type: DueDividerUiType = DueDividerUiType.Upcoming, + collapsed: Boolean = false, +) = DueDividerUi( + id, income, expense, label, type, collapsed +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/PeriodDataUi.kt b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/PeriodDataUi.kt new file mode 100644 index 0000000..e2c09dc --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/PeriodDataUi.kt @@ -0,0 +1,11 @@ +package com.ivy.core.ui.algorithm.trnhistory.data + +import androidx.compose.runtime.Immutable +import com.ivy.core.domain.pure.format.ValueUi + +@Immutable +data class PeriodDataUi( + val periodIncome: ValueUi, + val periodExpense: ValueUi, + val items: List, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/TransactionUi.kt b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/TransactionUi.kt new file mode 100644 index 0000000..b6318f7 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/TransactionUi.kt @@ -0,0 +1,45 @@ +package com.ivy.core.ui.algorithm.trnhistory.data + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.core.ui.data.dummyCategoryUi +import com.ivy.core.ui.data.transaction.TrnTimeUi +import com.ivy.core.ui.data.transaction.dummyTrnTimeActualUi +import com.ivy.data.transaction.TransactionType +import java.util.* + +@Immutable +data class TransactionUi( + val id: String, + val value: ValueUi, + val time: TrnTimeUi, + val account: AccountUi, + val category: CategoryUi?, + val title: String?, + val description: String?, + val type: TransactionType, +) : TrnListItemUi + +@Composable +fun dummyTransactionUi( + type: TransactionType, + value: ValueUi, + account: AccountUi = dummyAccountUi(), + category: CategoryUi? = dummyCategoryUi(), + title: String? = null, + description: String? = null, + time: TrnTimeUi = dummyTrnTimeActualUi(), +) = TransactionUi( + id = UUID.randomUUID().toString(), + type = type, + value = value, + account = account, + category = category, + title = title, + description = description, + time = time +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/TransferUi.kt b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/TransferUi.kt new file mode 100644 index 0000000..17505dc --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/TransferUi.kt @@ -0,0 +1,21 @@ +package com.ivy.core.ui.algorithm.trnhistory.data + +import androidx.compose.runtime.Immutable +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.transaction.TrnTimeUi + +@Immutable +data class TransferUi( + val batchId: String, + val fromAmount: ValueUi, + val fromAccount: AccountUi, + val toAccount: AccountUi, + val toAmount: ValueUi?, + val fee: ValueUi?, + val time: TrnTimeUi, + val category: CategoryUi?, + val title: String?, + val description: String?, +) : TrnListItemUi \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/TrnListItemUi.kt b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/TrnListItemUi.kt new file mode 100644 index 0000000..60436a2 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/TrnListItemUi.kt @@ -0,0 +1,6 @@ +package com.ivy.core.ui.algorithm.trnhistory.data + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface TrnListItemUi \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/raw/RawDateDivider.kt b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/raw/RawDateDivider.kt new file mode 100644 index 0000000..f10ac14 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/raw/RawDateDivider.kt @@ -0,0 +1,10 @@ +package com.ivy.core.ui.algorithm.trnhistory.data.raw + +import com.ivy.core.domain.algorithm.calc.data.RawStats +import java.time.LocalDate + +data class RawDateDivider( + override val id: String, + val date: LocalDate, + val cashflow: RawStats, +) : TrnListRawSectionKey \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/raw/RawDueDivider.kt b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/raw/RawDueDivider.kt new file mode 100644 index 0000000..1b2bd47 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/raw/RawDueDivider.kt @@ -0,0 +1,13 @@ +package com.ivy.core.ui.algorithm.trnhistory.data.raw + +import com.ivy.core.domain.algorithm.calc.data.RawStats + +data class RawDueDivider( + override val id: String, + val type: RawDividerType, + val rawStats: RawStats, +) : TrnListRawSectionKey + +enum class RawDividerType { + Upcoming, Overdue +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/raw/TrnListRawSectionKey.kt b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/raw/TrnListRawSectionKey.kt new file mode 100644 index 0000000..1a24579 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/algorithm/trnhistory/data/raw/TrnListRawSectionKey.kt @@ -0,0 +1,5 @@ +package com.ivy.core.ui.algorithm.trnhistory.data.raw + +sealed interface TrnListRawSectionKey { + val id: String +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/amount/AmountModal.kt b/core/ui/src/main/java/com/ivy/core/ui/amount/AmountModal.kt new file mode 100644 index 0000000..9069bb9 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/amount/AmountModal.kt @@ -0,0 +1,146 @@ +package com.ivy.core.ui.amount + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.* +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.ui.amount.components.AmountSection +import com.ivy.core.ui.amount.components.Keyboard +import com.ivy.core.ui.amount.data.CalculatorResultUi +import com.ivy.core.ui.currency.CurrencyPickerModal +import com.ivy.data.Value +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Positive +import com.ivy.design.l2_components.modal.components.Secondary +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l2_components.modal.scope.ModalActionsScope +import com.ivy.design.l2_components.modal.scope.ModalScope +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.hiltViewModelPreviewSafe +import com.ivy.resources.R + +/** + * @param key used to refresh the initial amount when the key changes + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun BoxScope.AmountModal( + modal: IvyModal, + initialAmount: Value?, + level: Int = 1, + calculatorVisible: MutableState = remember { mutableStateOf(false) }, + key: String? = null, + contentAbove: (@Composable ModalScope.() -> Unit)? = { + SpacerVer(height = 24.dp) + }, + moreActions: (@Composable ModalActionsScope.() -> Unit)? = null, + onAmountEnter: (Value) -> Unit, +) { + val viewModel: AmountModalViewModel? = hiltViewModelPreviewSafe(key = key) + val state = viewModel?.uiState?.collectAsState()?.value ?: previewState() + + LaunchedEffect(initialAmount, key) { + viewModel?.onEvent(AmountModalEvent.Initial(initialAmount)) + } + + val currencyPickerModal = rememberIvyModal() + + Modal( + modal = modal, + level = level, + contentModifier = Modifier.verticalScroll(rememberScrollState()), + actions = { + moreActions?.invoke(this) + Secondary( + text = null, + icon = R.drawable.ic_vue_edu_calculator, + feeling = if (calculatorVisible.value) + Feeling.Negative else Feeling.Positive, + hapticFeedback = true + ) { + calculatorVisible.value = !calculatorVisible.value + if (!calculatorVisible.value) { + viewModel?.onEvent(AmountModalEvent.CalculatorEquals) + } + } + SpacerHor(width = 8.dp) + Positive( + text = stringResource(R.string.enter), + icon = R.drawable.ic_round_check_24 + ) { + state.amount?.let(onAmountEnter) + modal.hide() + } + } + ) { + // Close the software keyboard if it's open + val keyboardController = LocalSoftwareKeyboardController.current + LaunchedEffect(Unit) { + keyboardController?.hide() + } + + contentAbove?.invoke(this) + AmountSection( + calculatorVisible = calculatorVisible.value, + expression = state.expression, + currency = state.currency, + amountInBaseCurrency = state.amountBaseCurrency, + calculatorTempResult = state.calculatorResult, + onPickCurrency = { currencyPickerModal.show() } + ) + SpacerVer(height = 12.dp) + Keyboard( + calculatorVisible = calculatorVisible.value, + onCalculatorEvent = { viewModel?.onEvent(AmountModalEvent.CalculatorOperator(it)) }, + onNumberEvent = { viewModel?.onEvent(AmountModalEvent.Number(it)) }, + onDecimalSeparator = { viewModel?.onEvent(AmountModalEvent.DecimalSeparator) }, + onBackspace = { viewModel?.onEvent(AmountModalEvent.Backspace) }, + onCalculatorC = { viewModel?.onEvent(AmountModalEvent.CalculatorC) }, + onCalculatorEquals = { viewModel?.onEvent(AmountModalEvent.CalculatorEquals) } + ) + SpacerVer(height = 16.dp) + } + + CurrencyPickerModal( + modal = currencyPickerModal, + level = 2, + initialCurrency = state.currency, + onCurrencyPick = { viewModel?.onEvent(AmountModalEvent.CurrencyChange(it)) } + ) +} + + +// region Previews +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + AmountModal( + modal = modal, + initialAmount = Value(0.0, "USD"), + onAmountEnter = {} + ) + } +} + +private fun previewState() = AmountModalState( + expression = "500.00", + currency = "USD", + amount = null, + amountBaseCurrency = ValueUi("1,032.55", "BGN"), + calculatorResult = CalculatorResultUi(result = "", isError = true) +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/amount/AmountModalEvent.kt b/core/ui/src/main/java/com/ivy/core/ui/amount/AmountModalEvent.kt new file mode 100644 index 0000000..a9e027b --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/amount/AmountModalEvent.kt @@ -0,0 +1,17 @@ +package com.ivy.core.ui.amount + +import com.ivy.data.CurrencyCode +import com.ivy.data.Value + +sealed interface AmountModalEvent { + data class Number(val number: Int) : AmountModalEvent + data class CalculatorOperator(val operator: com.ivy.math.calculator.CalculatorOperator) : + AmountModalEvent + object DecimalSeparator : AmountModalEvent + object Backspace : AmountModalEvent + object CalculatorEquals : AmountModalEvent + object CalculatorC : AmountModalEvent + + data class CurrencyChange(val currency: CurrencyCode) : AmountModalEvent + data class Initial(val initialAmount: Value?) : AmountModalEvent +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/amount/AmountModalState.kt b/core/ui/src/main/java/com/ivy/core/ui/amount/AmountModalState.kt new file mode 100644 index 0000000..ab437aa --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/amount/AmountModalState.kt @@ -0,0 +1,16 @@ +package com.ivy.core.ui.amount + +import androidx.compose.runtime.Immutable +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.ui.amount.data.CalculatorResultUi +import com.ivy.data.CurrencyCode +import com.ivy.data.Value + +@Immutable +internal data class AmountModalState( + val expression: String?, + val currency: CurrencyCode, + val amount: Value?, + val amountBaseCurrency: ValueUi?, + val calculatorResult: CalculatorResultUi?, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/amount/AmountModalViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/amount/AmountModalViewModel.kt new file mode 100644 index 0000000..87c7b7d --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/amount/AmountModalViewModel.kt @@ -0,0 +1,203 @@ +package com.ivy.core.ui.amount + +import com.ivy.common.isNotBlank +import com.ivy.common.isNotEmpty +import com.ivy.core.domain.FlowViewModel +import com.ivy.core.domain.action.exchange.ExchangeRatesFlow +import com.ivy.core.domain.action.settings.basecurrency.BaseCurrencyFlow +import com.ivy.core.domain.pure.exchange.exchange +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.format +import com.ivy.core.ui.amount.data.CalculatorResultUi +import com.ivy.data.Value +import com.ivy.data.exchange.ExchangeRates +import com.ivy.math.calculator.appendDecimalSeparator +import com.ivy.math.calculator.appendTo +import com.ivy.math.calculator.beautify +import com.ivy.math.calculator.hasObviousResult +import com.ivy.math.evaluate +import com.ivy.math.formatNumber +import com.ivy.math.localDecimalSeparator +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +internal class AmountModalViewModel @Inject constructor( + private val exchangeRatesFlow: ExchangeRatesFlow, + private val baseCurrencyFlow: BaseCurrencyFlow, +) : FlowViewModel() { + override val initialState = InternalState( + exchangeData = ExchangeRates(baseCurrency = "", rates = emptyMap()), + ) + + override val initialUi = AmountModalState( + expression = null, + currency = "", + amount = null, + amountBaseCurrency = null, + calculatorResult = CalculatorResultUi(result = "", isError = true) + ) + + // region Local state + private var overrideExpressionForInitial = false + + private val expression = MutableStateFlow("") + private val currency = MutableStateFlow("") + private val showExpressionError = MutableStateFlow(false) + // endregion + + // regin Flows + override val stateFlow: Flow = exchangeRatesFlow().map { + InternalState(exchangeData = it) + } + + override val uiFlow: Flow = combine( + expression, currency, calculateFlow(), amountBaseCurrencyFlow() + ) { expression, currency, (calcResult, expressionValue), amountBaseCurrency -> + AmountModalState( + expression = beautify(expression), + currency = currency, + amount = expressionValue?.let { Value(it, currency) }, + amountBaseCurrency = amountBaseCurrency, + calculatorResult = calcResult.takeIf { + calcResult.isError || !hasObviousResult(expression, expressionValue) + } + ) + } + + private fun amountBaseCurrencyFlow(): Flow = combine( + currency, baseCurrencyFlow(), calculateFlow(), exchangeRatesFlow() + ) { currency, baseCurrency, calcResult, rates -> + if (currency == baseCurrency) null else calcResult.second?.let { + exchange( + rates, + from = currency, + to = baseCurrency, + amount = it + ).orNull().takeIf { exchangedAmount -> + exchangedAmount != null && exchangedAmount > 0.0 + } + }?.let { exchangedAmount -> + format( + Value(amount = exchangedAmount, currency = baseCurrency), + shortenFiat = true + ) + } + } + + private fun calculateFlow(): Flow> = combine( + expression, showExpressionError + ) { expression, showExpressionError -> + val evaluated = evaluate(expression) + CalculatorResultUi( + result = evaluated?.let(::formatNumber) ?: "Error", + isError = evaluated == null && showExpressionError + ) to evaluated + } + // endregion + + + // region Event Handling + override suspend fun handleEvent(event: AmountModalEvent) = when (event) { + AmountModalEvent.Backspace -> handleBackspace() + AmountModalEvent.DecimalSeparator -> handleDecimalSeparator() + is AmountModalEvent.CalculatorOperator -> handleCalculatorOperator(event) + is AmountModalEvent.Number -> handleNumber(event) + is AmountModalEvent.CurrencyChange -> handleCurrencyChange(event) + is AmountModalEvent.Initial -> handleInitial(event) + AmountModalEvent.CalculatorC -> handleCalculatorC() + AmountModalEvent.CalculatorEquals -> handleCalculatorEquals() + } + + private fun handleBackspace() { + if (expression.value.isNotEmpty()) { + expression.value = expression.value.dropLast(1) + overrideExpressionForInitial = false // expression is not initial, disable override + } + } + + private fun handleDecimalSeparator() { + expression.value = appendDecimalSeparator( + expression = expression.value, + decimalSeparator = localDecimalSeparator(), + ) + overrideExpressionForInitial = false // expression is not initial, disable override + } + + // region Calculator + private fun handleCalculatorOperator(event: AmountModalEvent.CalculatorOperator) { + expression.value = appendTo(expression = expression.value, operator = event.operator) + overrideExpressionForInitial = false // expression is not initial, disable override + } + + private fun handleCalculatorC() { + expression.value = "" + overrideExpressionForInitial = false // expression is not initial, disable override + } + + private fun handleCalculatorEquals() { + val evaluated = evaluate(expression.value) + if (evaluated != null) { + expression.value = format(Value(evaluated, currency.value), shortenFiat = false).amount + } else if (expression.value.isNotBlank()) { + showExpressionError.value = true + } + overrideExpressionForInitial = false // expression is not initial, disable override + } + // endregion + + private fun handleNumber(event: AmountModalEvent.Number) { + // for better UX, allow the user to override the initial expression + val currentExpression = if (overrideExpressionForInitial) "" else expression.value + expression.value = appendTo(currentExpression, digit = event.number) + showExpressionError.value = false // remove shown error + + overrideExpressionForInitial = false // expression is not initial, disable override + } + + private suspend fun handleCurrencyChange(event: AmountModalEvent.CurrencyChange) { + val currency = currency.value + val newCurrency = event.currency + + val enteredValue = uiState.value.amount + if (newCurrency != currency && enteredValue != null) { + // Converted the entered amount to the new currency + val exchangeData = state.value.exchangeData + Timber.d("exchangeData = $exchangeData") + exchange( + exchangeData = exchangeData, + from = currency, to = newCurrency, + amount = enteredValue.amount + ).orNull()?.let { exchangedAmount -> + expression.value = format( + Value(exchangedAmount, newCurrency), shortenFiat = false + ).amount + } + } + + // update the currency in the UI + this.currency.value = newCurrency + + overrideExpressionForInitial = false // expression is not initial, disable override + } + + private fun handleInitial(event: AmountModalEvent.Initial) { + event.initialAmount?.let { initial -> + currency.value = initial.currency + if (initial.amount != 0.0) { + expression.value = format(initial, shortenFiat = false).amount + overrideExpressionForInitial = true + } + } + } + // endregion + + data class InternalState( + val exchangeData: ExchangeRates + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/amount/components/AmountSection.kt b/core/ui/src/main/java/com/ivy/core/ui/amount/components/AmountSection.kt new file mode 100644 index 0000000..3ca02e4 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/amount/components/AmountSection.kt @@ -0,0 +1,204 @@ +package com.ivy.core.ui.amount.components + +import androidx.compose.animation.* +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.ui.amount.data.CalculatorResultUi +import com.ivy.core.ui.amount.util.rememberDecimalSeparator +import com.ivy.data.CurrencyCode +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.style +import com.ivy.design.l1_buildingBlocks.B1Second +import com.ivy.design.l1_buildingBlocks.H2Second +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.data.none +import com.ivy.design.l2_components.button.Btn +import com.ivy.design.l2_components.button.TextIcon +import com.ivy.design.util.ComponentPreview +import com.ivy.resources.R + +@Composable +internal fun ColumnScope.AmountSection( + calculatorVisible: Boolean, + expression: String?, + currency: CurrencyCode, + amountInBaseCurrency: ValueUi?, + calculatorTempResult: CalculatorResultUi?, + onPickCurrency: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + H2Second( + text = expression ?: "0${rememberDecimalSeparator()}00", + fontWeight = FontWeight.Bold, + color = if (expression != null) + UI.colorsInverted.pure else UI.colors.neutral + ) + AnimatedVisibility( + visible = !calculatorVisible && currency.isNotBlank(), + enter = expandHorizontally() + fadeIn(), + exit = shrinkHorizontally() + fadeOut(), + ) { + CurrencyPicker(currency = currency, onClick = onPickCurrency) + } + } + AnimatedAmountInBaseCurrency( + calculatorVisible = calculatorVisible, + amountInBaseCurrency = amountInBaseCurrency, + ) + CalculatorTemporaryResult( + calculatorVisible = calculatorVisible, + result = calculatorTempResult, + ) +} + +// region Currency Picker +@Composable +private fun CurrencyPicker( + currency: CurrencyCode, + onClick: () -> Unit +) { + Btn.TextIcon( + modifier = Modifier.padding(start = 8.dp), + text = currency, + iconRight = R.drawable.round_expand_more_24, + iconPadding = 4.dp, + background = none(), + textStyle = UI.typoSecond.h2.style( + color = UI.colors.primary, + fontWeight = FontWeight.ExtraBold + ), + iconTint = UI.colors.primary, + onClick = onClick + ) +} +// endregion + +// region Amount in base currency +@Composable +private fun ColumnScope.AnimatedAmountInBaseCurrency( + calculatorVisible: Boolean, + amountInBaseCurrency: ValueUi? +) { + AnimatedVisibility( + modifier = Modifier.align(Alignment.CenterHorizontally), + visible = !calculatorVisible && amountInBaseCurrency != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Row( + modifier = Modifier + .padding(top = 0.dp) + .padding(horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + B1Second( + text = amountInBaseCurrency?.amount ?: "", + fontWeight = FontWeight.Normal + ) + SpacerHor(width = 4.dp) + B1Second( + text = amountInBaseCurrency?.currency ?: "", + fontWeight = FontWeight.Bold + ) + } + } +} +// endregion + +// region Calculator temp result +@Composable +private fun ColumnScope.CalculatorTemporaryResult( + calculatorVisible: Boolean, + result: CalculatorResultUi?, +) { + AnimatedVisibility( + modifier = Modifier.align(Alignment.CenterHorizontally), + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + visible = calculatorVisible && result != null + ) { + if (result != null) { + B1Second( + text = result.result, + fontWeight = FontWeight.ExtraBold, + color = if (result.isError) UI.colors.red else UI.colors.primary + ) + } + } +} +// endregion + + +// region Previews +@Preview +@Composable +private fun Preview() { + ComponentPreview { + Column { + AmountSection( + calculatorVisible = false, + expression = null, + currency = "USD", + amountInBaseCurrency = ValueUi( + amount = "10.00", + currency = "BGN" + ), + calculatorTempResult = null, + onPickCurrency = {} + ) + } + } +} + +@Preview +@Composable +private fun Preview_Calculator() { + ComponentPreview { + Column { + AmountSection( + calculatorVisible = true, + expression = "5+5", + currency = "USD", + amountInBaseCurrency = null, + calculatorTempResult = CalculatorResultUi( + result = "10.00", + isError = false + ), + onPickCurrency = {} + ) + } + } +} + +@Preview +@Composable +private fun Preview_Calculator_error() { + ComponentPreview { + Column { + AmountSection( + calculatorVisible = true, + expression = "5+", + currency = "USD", + amountInBaseCurrency = null, + calculatorTempResult = CalculatorResultUi( + result = "Error", + isError = true + ), + onPickCurrency = {} + ) + } + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/amount/components/Keyboard.kt b/core/ui/src/main/java/com/ivy/core/ui/amount/components/Keyboard.kt new file mode 100644 index 0000000..45d4e1f --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/amount/components/Keyboard.kt @@ -0,0 +1,352 @@ +package com.ivy.core.ui.amount.components + +import androidx.compose.animation.* +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.amount.util.rememberDecimalSeparator +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.rememberContrast +import com.ivy.design.l1_buildingBlocks.* +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.toColor +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.thenWhen +import com.ivy.math.calculator.CalculatorOperator +import com.ivy.resources.R + +// region Customize UI +private const val keypadOuterWeight = 1f +private const val keypadInnerWeight = 0.15f +private val keypadButtonBig = 90.dp +private val keypadButtonSmall = 82.dp +private val keyboardVerticalMargin = 4.dp +// endregion + +@Suppress("unused") +@Composable +internal fun ColumnScope.Keyboard( + calculatorVisible: Boolean, + onCalculatorEvent: (CalculatorOperator) -> Unit, + onNumberEvent: (Int) -> Unit, + onDecimalSeparator: () -> Unit, + onBackspace: () -> Unit, + onCalculatorC: () -> Unit, + onCalculatorEquals: () -> Unit, +) { + val keypadBtnSize by animateDpAsState( + targetValue = if (calculatorVisible) + keypadButtonSmall else keypadButtonBig + ) + CalculatorTopRow( + calculatorVisible = calculatorVisible, + keypadBtnSize = keypadBtnSize, + onCalculatorEvent = onCalculatorEvent, + onCalculatorC = onCalculatorC, + ) + // margin is built-in in calculator's top row + KeyboardRow { + SpacerWeight(weight = keypadOuterWeight) + KeypadButton(symbol = "7", size = keypadBtnSize, onClick = { onNumberEvent(7) }) + SpacerWeight(weight = keypadInnerWeight) + KeypadButton(symbol = "8", size = keypadBtnSize, onClick = { onNumberEvent(8) }) + SpacerWeight(weight = keypadInnerWeight) + KeypadButton(symbol = "9", size = keypadBtnSize, onClick = { onNumberEvent(9) }) + AnimatedCalculatorButton( + calculatorVisible = calculatorVisible, + symbol = "*", + onClick = { onCalculatorEvent(CalculatorOperator.Multiply) } + ) + SpacerWeight(weight = keypadOuterWeight) + } + SpacerVer(height = keyboardVerticalMargin) + KeyboardRow { + SpacerWeight(weight = keypadOuterWeight) + KeypadButton(symbol = "4", size = keypadBtnSize, onClick = { onNumberEvent(4) }) + SpacerWeight(weight = keypadInnerWeight) + KeypadButton(symbol = "5", size = keypadBtnSize, onClick = { onNumberEvent(5) }) + SpacerWeight(weight = keypadInnerWeight) + KeypadButton(symbol = "6", size = keypadBtnSize, onClick = { onNumberEvent(6) }) + AnimatedCalculatorButton( + calculatorVisible = calculatorVisible, + symbol = "-", + onClick = { onCalculatorEvent(CalculatorOperator.Minus) } + ) + SpacerWeight(weight = keypadOuterWeight) + } + SpacerVer(height = keyboardVerticalMargin) + KeyboardRow { + SpacerWeight(weight = keypadOuterWeight) + KeypadButton(symbol = "1", size = keypadBtnSize, onClick = { onNumberEvent(1) }) + SpacerWeight(weight = keypadInnerWeight) + KeypadButton(symbol = "2", size = keypadBtnSize, onClick = { onNumberEvent(2) }) + SpacerWeight(weight = keypadInnerWeight) + KeypadButton(symbol = "3", size = keypadBtnSize, onClick = { onNumberEvent(3) }) + AnimatedCalculatorButton( + calculatorVisible = calculatorVisible, + symbol = "+", + onClick = { onCalculatorEvent(CalculatorOperator.Plus) } + ) + SpacerWeight(weight = keypadOuterWeight) + } + SpacerVer(height = keyboardVerticalMargin) + KeyboardRow { + SpacerWeight(weight = keypadOuterWeight) + KeypadButton( + symbol = rememberDecimalSeparator().toString(), + size = keypadBtnSize, + onClick = onDecimalSeparator + ) + SpacerWeight(weight = keypadInnerWeight) + KeypadButton(symbol = "0", size = keypadBtnSize, onClick = { onNumberEvent(0) }) + SpacerWeight(weight = keypadInnerWeight) + BackSpaceButton( + size = keypadBtnSize, + onClick = onBackspace, + onLongClick = onCalculatorC + ) + AnimatedCalculatorButton( + calculatorVisible = calculatorVisible, + symbol = "=", + feeling = Feeling.Positive, + onClick = onCalculatorEquals, + ) + SpacerWeight(weight = keypadOuterWeight) + } +} + +// region Calculator +@Composable +private fun CalculatorTopRow( + calculatorVisible: Boolean, + keypadBtnSize: Dp, + onCalculatorEvent: (CalculatorOperator) -> Unit, + onCalculatorC: () -> Unit, +) { + AnimatedVisibility( + visible = calculatorVisible, + enter = expandHorizontally() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + KeyboardRow( + modifier = Modifier.padding(bottom = keyboardVerticalMargin) + ) { + SpacerWeight(weight = keypadOuterWeight) + KeypadButton( + symbol = "C", + size = keypadBtnSize, + visibility = Visibility.High, + feeling = Feeling.Negative, + onClick = onCalculatorC, + ) + SpacerWeight(weight = keypadInnerWeight) + KeypadButton( + symbol = "( )", + visibility = Visibility.High, + feeling = Feeling.Positive, + size = keypadBtnSize, + onClick = { onCalculatorEvent(CalculatorOperator.Brackets) } + ) + SpacerWeight(weight = keypadInnerWeight) + KeypadButton( + symbol = "%", + visibility = Visibility.High, + feeling = Feeling.Positive, + size = keypadBtnSize, + onClick = { onCalculatorEvent(CalculatorOperator.Percent) } + ) + SpacerWeight(weight = keypadInnerWeight) + KeypadButton( + symbol = "/", + size = keypadBtnSize, + visibility = Visibility.High, + feeling = Feeling.Positive, + onClick = { onCalculatorEvent(CalculatorOperator.Divide) } + ) + SpacerWeight(weight = keypadOuterWeight) + } + } +} + +@Composable +private fun RowScope.AnimatedCalculatorButton( + calculatorVisible: Boolean, + symbol: String, + modifier: Modifier = Modifier, + feeling: Feeling = Feeling.Positive, + onClick: () -> Unit +) { + if (calculatorVisible) { + SpacerWeight(weight = keypadInnerWeight) + } + AnimatedVisibility( + visible = calculatorVisible, + enter = expandHorizontally() + fadeIn(), + exit = shrinkHorizontally() + fadeOut(), + ) { + KeypadButton( + modifier = modifier, + feeling = feeling, + visibility = Visibility.High, + size = keypadButtonSmall, + symbol = symbol, + onClick = onClick + ) + } +} +// endregion + +@Composable +private fun KeyboardRow( + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + content = content, + ) +} + +// region Keypad Buttons +@Composable +private fun KeypadButton( + symbol: String, + size: Dp, + modifier: Modifier = Modifier, + visibility: Visibility = Visibility.Medium, + feeling: Feeling = Feeling.Positive, + onClick: () -> Unit +) { + KeypadButtonBox( + modifier = modifier, + feeling = feeling, + visibility = visibility, + size = size, + onClick = onClick + ) { + B1Second( + text = symbol, + color = when (visibility) { + Visibility.Focused, + Visibility.High -> + rememberContrast(feeling.toColor()) + Visibility.Medium, + Visibility.Low -> UI.colorsInverted.pure + }, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold + ) + } +} + +@Composable +private fun BackSpaceButton( + size: Dp, + modifier: Modifier = Modifier, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { + KeypadButtonBox( + modifier = modifier, + feeling = Feeling.Negative, + size = size, + onClick = onClick, + onLongClick = onLongClick + ) { + IconRes( + icon = R.drawable.outline_backspace_24, + tint = UI.colorsInverted.pure, + ) + } +} + +@Composable +private fun KeypadButtonBox( + feeling: Feeling, + size: Dp, + modifier: Modifier = Modifier, + visibility: Visibility = Visibility.Medium, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, + content: @Composable BoxScope.() -> Unit +) { + Box( + modifier = modifier + .size(size) + .clip(UI.shapes.circle) + .hapticClickable(onClick = onClick, onLongClick = onLongClick) + .padding(all = 4.dp) + .thenWhen { + when (visibility) { + Visibility.Focused, + Visibility.High -> background( + color = feeling.toColor(), + shape = UI.shapes.circle + ) + Visibility.Low, + Visibility.Medium -> border( + width = 1.dp, + color = feeling.toColor(), + shape = UI.shapes.circle + ) + } + }, + contentAlignment = Alignment.Center, + content = content + ) +} +// endregion + + +// region Previews +@Preview +@Composable +private fun Preview() { + ComponentPreview { + Column { + Keyboard( + calculatorVisible = false, + onCalculatorEvent = {}, + onNumberEvent = {}, + onDecimalSeparator = {}, + onBackspace = {}, + onCalculatorC = {}, + onCalculatorEquals = {} + ) + } + } +} + +@Preview +@Composable +private fun Preview_calculator_visible() { + ComponentPreview { + Column { + Keyboard( + calculatorVisible = false, + onCalculatorEvent = {}, + onNumberEvent = {}, + onDecimalSeparator = {}, + onBackspace = {}, + onCalculatorC = {}, + onCalculatorEquals = {} + ) + } + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/amount/data/CalculatorResultUi.kt b/core/ui/src/main/java/com/ivy/core/ui/amount/data/CalculatorResultUi.kt new file mode 100644 index 0000000..2afe9b4 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/amount/data/CalculatorResultUi.kt @@ -0,0 +1,9 @@ +package com.ivy.core.ui.amount.data + +import androidx.compose.runtime.Immutable + +@Immutable +data class CalculatorResultUi( + val result: String, + val isError: Boolean +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/amount/util/LocalSeparators.kt b/core/ui/src/main/java/com/ivy/core/ui/amount/util/LocalSeparators.kt new file mode 100644 index 0000000..155abc1 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/amount/util/LocalSeparators.kt @@ -0,0 +1,8 @@ +package com.ivy.core.ui.amount.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.ivy.math.localDecimalSeparator + +@Composable +fun rememberDecimalSeparator(): Char = remember { localDecimalSeparator() } \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/BaseCategoryModal.kt b/core/ui/src/main/java/com/ivy/core/ui/category/BaseCategoryModal.kt new file mode 100644 index 0000000..709d59f --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/BaseCategoryModal.kt @@ -0,0 +1,203 @@ +package com.ivy.core.ui.category + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.R +import com.ivy.core.ui.category.component.CategoryTypeSection +import com.ivy.core.ui.category.component.ParentCategoryButton +import com.ivy.core.ui.category.pickparent.ParentCategoryPickerModal +import com.ivy.core.ui.color.ColorButton +import com.ivy.core.ui.color.picker.ColorPickerModal +import com.ivy.core.ui.component.ItemIconNameRow +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.core.ui.data.icon.dummyIconSized +import com.ivy.core.ui.icon.picker.IconPickerModal +import com.ivy.data.ItemIconId +import com.ivy.data.category.CategoryType +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.DividerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Positive +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l2_components.modal.scope.ModalActionsScope +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.util.IvyPreview + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun BoxScope.BaseCategoryModal( + modal: IvyModal, + level: Int, + autoFocusNameInput: Boolean, + title: String, + nameInputHint: String, + positiveActionText: String, + icon: ItemIcon, + initialName: String, + color: Color, + parent: CategoryUi?, + type: CategoryType, + secondaryActions: (@Composable ModalActionsScope.() -> Unit)? = null, + contentBelow: (LazyListScope.() -> Unit)? = null, + onIconChange: (ItemIconId) -> Unit, + onNameChange: (String) -> Unit, + onColorChange: (Color) -> Unit, + onParentCategoryChange: (CategoryUi?) -> Unit, + onTypeChange: (CategoryType) -> Unit, + onSave: (SaveCategoryInfo) -> Unit, +) { + val iconPickerModal = rememberIvyModal() + val colorPickerModal = rememberIvyModal() + val chooseParentModal = rememberIvyModal() + + val keyboardController = LocalSoftwareKeyboardController.current + Modal( + modal = modal, + level = level, + actions = { + secondaryActions?.invoke(this) + Positive( + text = positiveActionText, + feeling = Feeling.Custom(color) + ) { + onSave( + SaveCategoryInfo( + color = color, + parent = parent, + ) + ) + keyboardController?.hide() + modal.hide() + } + } + ) { + LazyColumn(modifier = Modifier.weight(1f)) { + item(key = "modal_title") { + Title(text = title) + SpacerVer(height = 24.dp) + } + item(key = "icon_name_color") { + // Keep in one item because so the title + // won't disappear on scroll + ItemIconNameRow( + icon = icon, + color = color, + initialName = initialName, + nameInputHint = nameInputHint, + autoFocusInput = autoFocusNameInput, + onPickIcon = { + keyboardController?.hide() + iconPickerModal.show() + }, + onNameChange = onNameChange, + ) + SpacerVer(height = 16.dp) + ColorButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + color = color + ) { + keyboardController?.hide() + colorPickerModal.show() + } + SpacerVer(height = 16.dp) + } + item(key = "parent_category") { + ParentCategoryButton( + parent = parent, + color = color, + ) { + keyboardController?.hide() + chooseParentModal.show() + } + } + item(key = "line_divider") { + SpacerVer(height = 24.dp) + DividerHor() + SpacerVer(height = 12.dp) + } + item(key = "category_type") { + CategoryTypeSection( + type = type, + onSelect = onTypeChange + ) + } + contentBelow?.invoke(this) + item(key = "last_item_spacer") { + SpacerVer(height = 48.dp) // last spacer + } + } + } + + IconPickerModal( + modal = iconPickerModal, + level = level + 1, + initialIcon = icon, + color = color, + onIconPick = onIconChange, + ) + ColorPickerModal( + modal = colorPickerModal, + level = level + 1, + initialColor = color, + onColorPicked = onColorChange, + ) + ParentCategoryPickerModal( + modal = chooseParentModal, + level = level + 1, + selected = parent, + onPick = onParentCategoryChange + ) +} + +data class SaveCategoryInfo( + val color: Color, + val parent: CategoryUi? +) + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + BaseCategoryModal( + modal = modal, + level = 1, + autoFocusNameInput = false, + title = stringResource(R.string.edit_category), + nameInputHint = stringResource(R.string.category_name), + positiveActionText = stringResource(R.string.save), + icon = dummyIconSized(R.drawable.ic_custom_category_m), + color = UI.colors.primary, + initialName = "Category", + parent = null, + type = CategoryType.Both, + onNameChange = {}, + onIconChange = {}, + onSave = {}, + onColorChange = {}, + onParentCategoryChange = {}, + onTypeChange = {}, + ) + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/CategoryBadge.kt b/core/ui/src/main/java/com/ivy/core/ui/category/CategoryBadge.kt new file mode 100644 index 0000000..d90aa94 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/CategoryBadge.kt @@ -0,0 +1,57 @@ +package com.ivy.core.ui.category + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import com.ivy.core.ui.R +import com.ivy.core.ui.component.BadgeComponent +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.dummyCategoryUi +import com.ivy.core.ui.data.icon.dummyIconSized +import com.ivy.core.ui.data.icon.dummyIconUnknown +import com.ivy.design.l0_system.color.Black +import com.ivy.design.l0_system.color.Purple +import com.ivy.design.util.ComponentPreview + +@Composable +fun CategoryBadge( + category: CategoryUi, + background: Color = category.color, + onClick: (() -> Unit)? = null +) { + BadgeComponent( + icon = category.icon, + text = category.name, + background = background, + onClick = onClick, + ) +} + +@Preview +@Composable +private fun Preview_Black() { + ComponentPreview { + CategoryBadge( + category = dummyCategoryUi( + name = "Cash", + icon = dummyIconUnknown(R.drawable.ic_vue_building_house) + ), + background = Black + ) + } +} + +@Preview +@Composable +private fun Preview_Color() { + ComponentPreview { + + CategoryBadge( + category = dummyCategoryUi( + name = "Cash", + icon = dummyIconSized(R.drawable.ic_custom_category_s), + color = Purple, + ) + ) + } +} diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/component/CategoryTypeSection.kt b/core/ui/src/main/java/com/ivy/core/ui/category/component/CategoryTypeSection.kt new file mode 100644 index 0000000..0025c38 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/component/CategoryTypeSection.kt @@ -0,0 +1,106 @@ +package com.ivy.core.ui.category.component + +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.data.category.CategoryType +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.B1 +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview + + +@Composable +fun CategoryTypeSection( + type: CategoryType, + onSelect: (CategoryType) -> Unit +) { + B1( + modifier = Modifier.padding(start = 24.dp), + text = "Category type" + ) + SpacerVer(height = 8.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CategoryTypeButton( + modifier = Modifier.weight(1f), + type = CategoryType.Income, + selected = type == CategoryType.Income, + onSelect = onSelect + ) + SpacerHor(width = 8.dp) + CategoryTypeButton( + modifier = Modifier.weight(1f), + type = CategoryType.Expense, + selected = type == CategoryType.Expense, + onSelect = onSelect + ) + SpacerHor(width = 8.dp) + CategoryTypeButton( + modifier = Modifier.weight(1f), + type = CategoryType.Both, + selected = type == CategoryType.Both, + onSelect = onSelect + ) + } +} + +@Composable +private fun CategoryTypeButton( + type: CategoryType, + selected: Boolean, + modifier: Modifier = Modifier, + onSelect: (CategoryType) -> Unit +) { + IvyButton( + modifier = modifier, + size = ButtonSize.Small, + visibility = if (selected) Visibility.High else Visibility.Medium, + feeling = if (selected) Feeling.Custom( + when (type) { + CategoryType.Income -> UI.colors.green + CategoryType.Expense -> UI.colors.red + CategoryType.Both -> UI.colors.primary + } + ) else Feeling.Neutral, + text = when (type) { + CategoryType.Expense -> "Expense" + CategoryType.Income -> "Income" + CategoryType.Both -> "Both" + }, + icon = null + ) { + onSelect(type) + } +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + ComponentPreview { + Column { + CategoryTypeSection( + type = CategoryType.Both, + onSelect = {} + ) + } + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/component/ParentCategoryButton.kt b/core/ui/src/main/java/com/ivy/core/ui/category/component/ParentCategoryButton.kt new file mode 100644 index 0000000..c8029ce --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/component/ParentCategoryButton.kt @@ -0,0 +1,98 @@ +package com.ivy.core.ui.category.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.R +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.dummyCategoryUi +import com.ivy.core.ui.data.icon.IconSize +import com.ivy.core.ui.icon.ItemIcon +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.Purple +import com.ivy.design.l0_system.color.rememberContrast +import com.ivy.design.l1_buildingBlocks.B1 +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview + +@Composable +internal fun ColumnScope.ParentCategoryButton( + parent: CategoryUi?, + modifier: Modifier = Modifier, + color: Color, + onClick: () -> Unit +) { + B1( + modifier = Modifier.padding(start = 24.dp), + text = "Parent category" + ) + SpacerVer(height = 8.dp) + if (parent != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(UI.shapes.rounded) + .background(parent.color) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + val contrast = rememberContrast(color = parent.color) + ItemIcon(itemIcon = parent.icon, size = IconSize.S, tint = contrast) + SpacerHor(width = 12.dp) + B1(text = parent.name, color = contrast) + } + } else { + IvyButton( + modifier = modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Custom(color), + text = "Choose parent", + icon = R.drawable.ic_custom_category_s, + onClick = onClick + ) + } +} + + +// region Preview +@Preview +@Composable +private fun Preview_None() { + ComponentPreview { + Column { + ParentCategoryButton( + parent = null, + color = Purple, + onClick = {} + ) + } + } +} + +@Preview +@Composable +private fun Preview_Selected() { + ComponentPreview { + Column { + ParentCategoryButton( + parent = dummyCategoryUi("Parent"), + color = Purple, + onClick = {} + ) + } + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/create/CreateCategoryEvent.kt b/core/ui/src/main/java/com/ivy/core/ui/category/create/CreateCategoryEvent.kt new file mode 100644 index 0000000..5d48324 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/create/CreateCategoryEvent.kt @@ -0,0 +1,19 @@ +package com.ivy.core.ui.category.create + +import androidx.compose.ui.graphics.Color +import com.ivy.core.ui.data.CategoryUi +import com.ivy.data.ItemIconId +import com.ivy.data.category.CategoryType + +internal sealed interface CreateCategoryEvent { + data class CreateCategory( + val color: Color, + val parent: CategoryUi? + ) : CreateCategoryEvent + + data class IconChange(val iconId: ItemIconId) : CreateCategoryEvent + + data class NameChange(val name: String) : CreateCategoryEvent + + data class CategoryTypeChange(val categoryType: CategoryType) : CreateCategoryEvent +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/create/CreateCategoryModal.kt b/core/ui/src/main/java/com/ivy/core/ui/category/create/CreateCategoryModal.kt new file mode 100644 index 0000000..ba06f58 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/create/CreateCategoryModal.kt @@ -0,0 +1,75 @@ +package com.ivy.core.ui.category.create + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.* +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.ivy.core.ui.R +import com.ivy.core.ui.category.BaseCategoryModal +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.icon.dummyIconSized +import com.ivy.data.category.CategoryType +import com.ivy.design.l0_system.UI +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.hiltViewModelPreviewSafe + +@Composable +fun BoxScope.CreateCategoryModal( + modal: IvyModal, + level: Int = 1 +) { + val viewModel: CreateCategoryViewModel? = hiltViewModelPreviewSafe() + val state = viewModel?.uiState?.collectAsState()?.value ?: previewState() + + val primary = UI.colors.primary + var color by remember(primary) { mutableStateOf(primary) } + var parent by remember { mutableStateOf(null) } + var type by remember { mutableStateOf(CategoryType.Both) } + + val newCategoryText = "New Category" + BaseCategoryModal( + modal = modal, + level = level, + autoFocusNameInput = true, + title = newCategoryText, + nameInputHint = newCategoryText, + positiveActionText = stringResource(R.string.add_category), + icon = state.icon, + initialName = "", + color = color, + parent = parent, + type = type, + onNameChange = { viewModel?.onEvent(CreateCategoryEvent.NameChange(it)) }, + onIconChange = { viewModel?.onEvent(CreateCategoryEvent.IconChange(it)) }, + onParentCategoryChange = { parent = it }, + onTypeChange = { type = it }, + onColorChange = { color = it }, + onSave = { + viewModel?.onEvent( + CreateCategoryEvent.CreateCategory( + color = it.color, + parent = it.parent + ) + ) + } + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + CreateCategoryModal(modal = modal) + } +} + +private fun previewState() = CreateCategoryState( + icon = dummyIconSized(R.drawable.ic_custom_category_m) +) +// endregion diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/create/CreateCategoryState.kt b/core/ui/src/main/java/com/ivy/core/ui/category/create/CreateCategoryState.kt new file mode 100644 index 0000000..f700c91 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/create/CreateCategoryState.kt @@ -0,0 +1,9 @@ +package com.ivy.core.ui.category.create + +import androidx.compose.runtime.Immutable +import com.ivy.core.ui.data.icon.ItemIcon + +@Immutable +internal data class CreateCategoryState( + val icon: ItemIcon +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/create/CreateCategoryViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/category/create/CreateCategoryViewModel.kt new file mode 100644 index 0000000..878604a --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/create/CreateCategoryViewModel.kt @@ -0,0 +1,91 @@ +package com.ivy.core.ui.category.create + +import androidx.compose.ui.graphics.toArgb +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.toUUID +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.action.category.NewCategoryOrderNumAct +import com.ivy.core.domain.action.category.WriteCategoriesAct +import com.ivy.core.domain.action.data.Modify +import com.ivy.core.ui.R +import com.ivy.core.ui.action.DefaultTo +import com.ivy.core.ui.action.ItemIconAct +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.data.ItemIconId +import com.ivy.data.Sync +import com.ivy.data.SyncState +import com.ivy.data.category.Category +import com.ivy.data.category.CategoryState +import com.ivy.data.category.CategoryType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import java.util.* +import javax.inject.Inject + +@HiltViewModel +internal class CreateCategoryViewModel @Inject constructor( + private val itemIconAct: ItemIconAct, + private val writeCategoriesAct: WriteCategoriesAct, + private val newCategoryOrderNumAct: NewCategoryOrderNumAct, + private val timeProvider: TimeProvider, +) : SimpleFlowViewModel() { + override val initialUi = CreateCategoryState( + icon = ItemIcon.Sized( + iconS = R.drawable.ic_custom_category_s, + iconM = R.drawable.ic_custom_category_m, + iconL = R.drawable.ic_custom_category_l, + iconId = null + ) + ) + + private var name = "" + private val iconId = MutableStateFlow(null) + private val categoryType = MutableStateFlow(CategoryType.Both) + + override val uiFlow: Flow = iconId.map { iconId -> + CreateCategoryState( + icon = itemIconAct(ItemIconAct.Input(iconId, DefaultTo.Account)) + ) + } + + // region Event Handling + override suspend fun handleEvent(event: CreateCategoryEvent) = when (event) { + is CreateCategoryEvent.CreateCategory -> createCategory(event) + is CreateCategoryEvent.IconChange -> handleIconPick(event) + is CreateCategoryEvent.NameChange -> handleNameChange(event) + is CreateCategoryEvent.CategoryTypeChange -> handleCategoryTypeChange(event) + } + + private suspend fun createCategory(event: CreateCategoryEvent.CreateCategory) { + val new = Category( + id = UUID.randomUUID(), + name = name, + color = event.color.toArgb(), + icon = iconId.value, + parentCategoryId = event.parent?.id?.toUUID(), + orderNum = newCategoryOrderNumAct(Unit), + state = CategoryState.Default, + type = categoryType.value, + sync = Sync( + state = SyncState.Syncing, + lastUpdated = timeProvider.timeNow(), + ), + ) + writeCategoriesAct(Modify.save(new)) + } + + private fun handleIconPick(event: CreateCategoryEvent.IconChange) { + iconId.value = event.iconId + } + + private fun handleNameChange(event: CreateCategoryEvent.NameChange) { + name = event.name + } + + private fun handleCategoryTypeChange(event: CreateCategoryEvent.CategoryTypeChange) { + categoryType.value = event.categoryType + } + // endregion +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/edit/EditCategoryEvent.kt b/core/ui/src/main/java/com/ivy/core/ui/category/edit/EditCategoryEvent.kt new file mode 100644 index 0000000..5d2285c --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/edit/EditCategoryEvent.kt @@ -0,0 +1,26 @@ +package com.ivy.core.ui.category.edit + +import androidx.compose.ui.graphics.Color +import com.ivy.core.ui.data.CategoryUi +import com.ivy.data.ItemIconId +import com.ivy.data.category.CategoryType + +internal sealed interface EditCategoryEvent { + data class Initial(val categoryId: String) : EditCategoryEvent + + object EditCategory : EditCategoryEvent + + data class IconChange(val iconId: ItemIconId) : EditCategoryEvent + + data class NameChange(val name: String) : EditCategoryEvent + + data class ColorChange(val color: Color) : EditCategoryEvent + + data class ParentChange(val parent: CategoryUi?) : EditCategoryEvent + + data class TypeChange(val type: CategoryType) : EditCategoryEvent + + object Archive : EditCategoryEvent + object Unarchive : EditCategoryEvent + object Delete : EditCategoryEvent +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/edit/EditCategoryModal.kt b/core/ui/src/main/java/com/ivy/core/ui/category/edit/EditCategoryModal.kt new file mode 100644 index 0000000..5c2d7eb --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/edit/EditCategoryModal.kt @@ -0,0 +1,126 @@ +package com.ivy.core.ui.category.edit + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.R +import com.ivy.core.ui.category.BaseCategoryModal +import com.ivy.core.ui.category.edit.component.DeleteCategoryModal +import com.ivy.core.ui.data.icon.dummyIconSized +import com.ivy.data.category.CategoryType +import com.ivy.design.l0_system.color.Purple +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.button.ArchiveButton +import com.ivy.design.l3_ivyComponents.button.DeleteButton +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.hiltViewModelPreviewSafe + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun BoxScope.EditCategoryModal( + modal: IvyModal, + categoryId: String, + level: Int = 1, +) { + val viewModel: EditCategoryViewModel? = hiltViewModelPreviewSafe() + val state = viewModel?.uiState?.collectAsState()?.value ?: previewState() + + LaunchedEffect(categoryId) { + viewModel?.onEvent(EditCategoryEvent.Initial(categoryId)) + } + + val deleteModal = rememberIvyModal() + + val keyboardController = LocalSoftwareKeyboardController.current + BaseCategoryModal( + modal = modal, + level = level, + autoFocusNameInput = false, + title = stringResource(R.string.edit_category), + nameInputHint = stringResource(R.string.category_name), + positiveActionText = stringResource(R.string.save), + secondaryActions = { + ArchiveButton( + archived = state.archived, + color = state.color, + onArchive = { + keyboardController?.hide() + modal.hide() + viewModel?.onEvent(EditCategoryEvent.Archive) + }, + onUnarchive = { + keyboardController?.hide() + modal.hide() + viewModel?.onEvent(EditCategoryEvent.Unarchive) + } + ) + SpacerHor(width = 8.dp) + DeleteButton { + keyboardController?.hide() + deleteModal.show() + } + SpacerHor(width = 12.dp) + }, + icon = state.icon, + initialName = state.initialName, + color = state.color, + parent = state.parent, + type = state.type, + onNameChange = { viewModel?.onEvent(EditCategoryEvent.NameChange(it)) }, + onIconChange = { viewModel?.onEvent(EditCategoryEvent.IconChange(it)) }, + onColorChange = { viewModel?.onEvent(EditCategoryEvent.ColorChange(it)) }, + onTypeChange = { viewModel?.onEvent(EditCategoryEvent.TypeChange(it)) }, + onParentCategoryChange = { viewModel?.onEvent(EditCategoryEvent.ParentChange(it)) }, + onSave = { viewModel?.onEvent(EditCategoryEvent.EditCategory) } + ) + + DeleteCategoryModal( + modal = deleteModal, + level = level + 1, + categoryName = state.initialName, + archived = state.archived, + onArchive = { + keyboardController?.hide() + modal.hide() + viewModel?.onEvent(EditCategoryEvent.Archive) + }, + onDelete = { + keyboardController?.hide() + modal.hide() + viewModel?.onEvent(EditCategoryEvent.Delete) + } + ) +} + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + EditCategoryModal( + modal = modal, + categoryId = "" + ) + } +} + +private fun previewState() = EditCategoryState( + categoryId = "", + icon = dummyIconSized(R.drawable.ic_custom_category_m), + initialName = "Category", + parent = null, + color = Purple, + archived = false, + type = CategoryType.Both, +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/edit/EditCategoryState.kt b/core/ui/src/main/java/com/ivy/core/ui/category/edit/EditCategoryState.kt new file mode 100644 index 0000000..beb7c93 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/edit/EditCategoryState.kt @@ -0,0 +1,18 @@ +package com.ivy.core.ui.category.edit + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.data.category.CategoryType + +@Immutable +internal data class EditCategoryState( + val categoryId: String, + val icon: ItemIcon, + val color: Color, + val initialName: String, + val parent: CategoryUi?, + val archived: Boolean, + val type: CategoryType, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/edit/EditCategoryViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/category/edit/EditCategoryViewModel.kt new file mode 100644 index 0000000..8994a0a --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/edit/EditCategoryViewModel.kt @@ -0,0 +1,216 @@ +package com.ivy.core.ui.category.edit + +import android.annotation.SuppressLint +import android.content.Context +import android.widget.Toast +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.toUUID +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.action.category.CategoriesFlow +import com.ivy.core.domain.action.category.CategoryByIdAct +import com.ivy.core.domain.action.category.WriteCategoriesAct +import com.ivy.core.domain.action.data.Modify +import com.ivy.core.ui.R +import com.ivy.core.ui.action.DefaultTo +import com.ivy.core.ui.action.ItemIconAct +import com.ivy.core.ui.action.mapping.MapCategoryUiAct +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.data.ItemIconId +import com.ivy.data.Sync +import com.ivy.data.SyncState +import com.ivy.data.category.Category +import com.ivy.data.category.CategoryState +import com.ivy.data.category.CategoryType +import com.ivy.design.l0_system.color.Purple +import com.ivy.design.l0_system.color.toComposeColor +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +@SuppressLint("StaticFieldLeak") +@HiltViewModel +internal class EditCategoryViewModel @Inject constructor( + @ApplicationContext + private val appContext: Context, + private val itemIconAct: ItemIconAct, + private val writeCategoriesAct: WriteCategoriesAct, + private val categoryById: CategoryByIdAct, + private val categoriesFlow: CategoriesFlow, + private val mapCategoryUiAct: MapCategoryUiAct, + private val timeProvider: TimeProvider, +) : SimpleFlowViewModel() { + override val initialUi = EditCategoryState( + categoryId = "", + icon = ItemIcon.Sized( + iconM = R.drawable.ic_custom_category_m, + iconS = R.drawable.ic_custom_category_s, + iconL = R.drawable.ic_custom_category_l, + iconId = null + ), + color = Purple, + initialName = "", + parent = null, + archived = false, + type = CategoryType.Both, + ) + + private val category = MutableStateFlow(null) + private var name = "" + private val initialName = MutableStateFlow(initialUi.initialName) + private val iconId = MutableStateFlow(null) + private val color = MutableStateFlow(initialUi.color) + private val parentCategoryId = MutableStateFlow(null) + private val archived = MutableStateFlow(initialUi.archived) + private val type = MutableStateFlow(initialUi.type) + + override val uiFlow: Flow = combine( + category, headerFlow(), secondaryFlow(), parentFlow() + ) { category, header, secondary, parent -> + EditCategoryState( + categoryId = category?.id?.toString() ?: "", + icon = itemIconAct(ItemIconAct.Input(header.iconId, DefaultTo.Category)), + initialName = header.initialName, + color = header.color, + parent = parent, + archived = secondary.archived, + type = secondary.type, + ) + } + + private fun headerFlow(): Flow
= combine( + iconId, initialName, color, + ) { iconId, initialName, color -> + Header(iconId = iconId, initialName = initialName, color = color) + } + + private fun secondaryFlow(): Flow = combine( + type, archived + ) { type, archived -> + Secondary(type, archived) + } + + private fun parentFlow(): Flow = combine( + categoriesFlow(), parentCategoryId + ) { categories, parentId -> + categories.firstOrNull { it.id.toString() == parentId } + ?.let { mapCategoryUiAct(it) } + } + + + // region Event Handling + override suspend fun handleEvent(event: EditCategoryEvent) = when (event) { + is EditCategoryEvent.Initial -> handleInitial(event) + EditCategoryEvent.EditCategory -> editCategory() + is EditCategoryEvent.IconChange -> handleIconPick(event) + is EditCategoryEvent.NameChange -> handleNameChange(event) + is EditCategoryEvent.ColorChange -> handleColorChange(event) + is EditCategoryEvent.ParentChange -> handleFolderChange(event) + is EditCategoryEvent.TypeChange -> handleTypeChange(event) + EditCategoryEvent.Archive -> handleArchive() + EditCategoryEvent.Unarchive -> handleUnarchive() + EditCategoryEvent.Delete -> handleDelete() + } + + private suspend fun handleInitial(event: EditCategoryEvent.Initial) { + // we need a snapshot of the category at this given point in time + // => flow isn't good for that use-case + categoryById(event.categoryId)?.let { + category.value = it + name = it.name + initialName.value = it.name + iconId.value = it.icon + color.value = it.color.toComposeColor() + parentCategoryId.value = it.parentCategoryId?.toString() + type.value = it.type + archived.value = it.state == CategoryState.Archived + } + } + + private suspend fun editCategory() { + val updated = category.value?.copy( + name = name, + color = color.value.toArgb(), + parentCategoryId = parentCategoryId.value?.toUUID(), + icon = iconId.value, + type = type.value, + sync = Sync( + state = SyncState.Syncing, + lastUpdated = timeProvider.timeNow(), + ) + ) + if (updated != null) { + writeCategoriesAct(Modify.save(updated)) + } + } + + private fun handleIconPick(event: EditCategoryEvent.IconChange) { + iconId.value = event.iconId + } + + private fun handleNameChange(event: EditCategoryEvent.NameChange) { + name = event.name + } + + private fun handleColorChange(event: EditCategoryEvent.ColorChange) { + color.value = event.color + } + + private fun handleFolderChange(event: EditCategoryEvent.ParentChange) { + parentCategoryId.value = event.parent?.id + } + + private fun handleTypeChange(event: EditCategoryEvent.TypeChange) { + type.value = event.type + } + + private suspend fun handleArchive() { + archived.value = true + updateArchived(state = CategoryState.Archived) + showToast("Category archived") + } + + private suspend fun handleUnarchive() { + archived.value = false + updateArchived(state = CategoryState.Default) + showToast("Category unarchived") + } + + private fun showToast(text: String) { + Toast.makeText(appContext, text, Toast.LENGTH_LONG).show() + } + + private suspend fun updateArchived(state: CategoryState) { + val updated = category.value?.copy( + state = state, + sync = Sync( + state = SyncState.Syncing, + lastUpdated = timeProvider.timeNow(), + ) + ) + if (updated != null) { + writeCategoriesAct(Modify.save(updated)) + } + } + + private suspend fun handleDelete() { + category.value?.let { + writeCategoriesAct(Modify.delete(it.id.toString())) + } + } + // endregion + + private data class Header( + val iconId: ItemIconId?, + val initialName: String, + val color: Color, + ) + + private data class Secondary( + val type: CategoryType, + val archived: Boolean, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/edit/component/DeleteCategoryModal.kt b/core/ui/src/main/java/com/ivy/core/ui/category/edit/component/DeleteCategoryModal.kt new file mode 100644 index 0000000..54f38d7 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/edit/component/DeleteCategoryModal.kt @@ -0,0 +1,125 @@ +package com.ivy.core.ui.category.edit.component + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.R +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Body +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview + +@Composable +fun BoxScope.DeleteCategoryModal( + modal: IvyModal, + level: Int = 1, + archived: Boolean, + categoryName: String, + onArchive: () -> Unit, + onDelete: () -> Unit, +) { + Modal( + modal = modal, + level = level, + actions = { + IvyButton( + size = ButtonSize.Small, + visibility = Visibility.Focused, + feeling = Feeling.Negative, + text = "Delete forever", + icon = R.drawable.ic_round_delete_forever_24 + ) { + modal.hide() + onDelete() + } + } + ) { + Title( + text = "Delete \"$categoryName\" category forever?", + color = UI.colors.red + ) + SpacerVer(height = 24.dp) + Body( + text = bodyText( + categoryname = categoryName, + archived = archived + ) + ) + if (!archived) { + SpacerVer(height = 12.dp) + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = "Archive", + icon = R.drawable.round_archive_24 + ) { + modal.hide() + onArchive() + } + } + SpacerVer(height = 48.dp) + } +} + +private fun bodyText( + categoryname: String, + archived: Boolean +): String { + val baseText = + "DANGER! Deleting \"$categoryname\" category will make all of its transactions " + + "\"unspecified\" (uncategorized). This operation CANNOT be undone and " + + "will affect your statistics!" + + " Please, be careful otherwise you may lose your data." + + val unarchivedText = + "\n\nIf you don't want to see this category but want preserve its transactions," + + " a better option would be to just archive it." + return if (archived) baseText else baseText + unarchivedText +} + +// region Preview +@Preview +@Composable +private fun Preview_Unarchived() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + DeleteCategoryModal( + modal = modal, + categoryName = "Category 1", + archived = false, + onArchive = {}, + onDelete = {} + ) + } +} + +@Preview +@Composable +private fun Preview_Archived() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + DeleteCategoryModal( + modal = modal, + categoryName = "Category 1", + archived = true, + onArchive = {}, + onDelete = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/pick/CategoryPickerEvent.kt b/core/ui/src/main/java/com/ivy/core/ui/category/pick/CategoryPickerEvent.kt new file mode 100644 index 0000000..2b8088b --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/pick/CategoryPickerEvent.kt @@ -0,0 +1,13 @@ +package com.ivy.core.ui.category.pick + +import com.ivy.core.ui.category.pick.data.SelectableCategoryUi +import com.ivy.core.ui.data.CategoryUi +import com.ivy.data.transaction.TransactionType + +sealed interface CategoryPickerEvent { + data class Initial(val trnType: TransactionType?) : CategoryPickerEvent + data class CategorySelected(val category: CategoryUi?) : CategoryPickerEvent + + data class ExpandParent(val parent: SelectableCategoryUi) : CategoryPickerEvent + object CollapseParent : CategoryPickerEvent +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/pick/CategoryPickerModal.kt b/core/ui/src/main/java/com/ivy/core/ui/category/pick/CategoryPickerModal.kt new file mode 100644 index 0000000..9df07bd --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/pick/CategoryPickerModal.kt @@ -0,0 +1,226 @@ +package com.ivy.core.ui.category.pick + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ivy.core.ui.R +import com.ivy.core.ui.category.create.CreateCategoryModal +import com.ivy.core.ui.category.pick.component.PickerCategoriesRow +import com.ivy.core.ui.category.pick.component.PickerParentCategory +import com.ivy.core.ui.category.pick.data.CategoryPickerItemUi +import com.ivy.core.ui.category.pick.data.SelectableCategoryUi +import com.ivy.core.ui.category.pick.data.dummySelectableCategoryUi +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.dummyCategoryUi +import com.ivy.core.ui.modal.ViewModelModal +import com.ivy.data.transaction.TransactionType +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.previewModal +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview + +@Composable +fun BoxScope.CategoryPickerModal( + modal: IvyModal, + level: Int = 1, + trnType: TransactionType?, + selected: CategoryUi?, + onPick: (CategoryUi?) -> Unit, +) { + val createCategoryModal = rememberIvyModal() + + ViewModelModal( + modal = modal, + provideViewModel = { hiltViewModel() }, + previewState = { previewState() }, + level = level, + actions = { _, onEvent -> + IvyButton( + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = Feeling.Neutral, + text = "Unspecified", + icon = R.drawable.ic_custom_category_s, + ) { + onEvent(CategoryPickerEvent.CategorySelected(null)) + onPick(null) + modal.hide() + } + } + ) { state, onEvent -> + LaunchedEffect(trnType) { + onEvent(CategoryPickerEvent.Initial(trnType)) + } + + LaunchedEffect(selected) { + onEvent(CategoryPickerEvent.CategorySelected(selected)) + onEvent(CategoryPickerEvent.CollapseParent) + } + + + LazyColumn(modifier = Modifier.weight(1f, fill = false)) { + item(key = "modal_title") { + Title(text = stringResource(id = R.string.choose_category)) + SpacerVer(height = 16.dp) + } + pickerItems( + items = state.items, + onCategorySelect = { + onEvent(CategoryPickerEvent.CategorySelected(it)) + onPick(it) + modal.hide() + }, + onExpandParent = { + onEvent(CategoryPickerEvent.ExpandParent(it)) + }, + ) + item(key = "add_category_btn") { + AddCategoryButton { + createCategoryModal.show() + } + } + item(key = "last_item_space") { + SpacerVer(height = 24.dp) + } + } + } + + CreateCategoryModal( + modal = createCategoryModal, + level = level + 1, + ) +} + +private fun LazyListScope.pickerItems( + items: List, + onCategorySelect: (CategoryUi) -> Unit, + onExpandParent: (SelectableCategoryUi) -> Unit, +) { + items( + items = items, + key = { + when (it) { + is CategoryPickerItemUi.CategoriesRow -> it.categories.first().category.id + is CategoryPickerItemUi.ParentCategory -> it.parent.category.id + } + } + ) { item -> + when (item) { + is CategoryPickerItemUi.CategoriesRow -> { + SpacerVer(height = 12.dp) + PickerCategoriesRow( + categories = item.categories, + onSelect = { onCategorySelect(it.category) } + ) + } + is CategoryPickerItemUi.ParentCategory -> { + SpacerVer(height = 12.dp) + PickerParentCategory( + item = item, + onParentClick = { + if (item.expanded) { + onCategorySelect(item.parent.category) + } else { + onExpandParent(item.parent) + } + }, + onChildClick = { onCategorySelect(it) } + ) + } + } + } +} + +@Composable +private fun AddCategoryButton( + onClick: () -> Unit, +) { + IvyButton( + modifier = Modifier + .padding(top = 12.dp) + .padding(start = 12.dp), + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = stringResource(R.string.add_category), + icon = R.drawable.ic_round_add_24, + onClick = onClick, + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = previewModal() + CategoryPickerModal( + modal = modal, + trnType = TransactionType.Expense, + selected = dummyCategoryUi(), + onPick = {} + ) + } +} + +private fun previewState() = CategoryPickerState( + items = listOf( + CategoryPickerItemUi.CategoriesRow( + categories = listOf( + dummySelectableCategoryUi(), + dummySelectableCategoryUi(), + dummySelectableCategoryUi(), + ) + ), + CategoryPickerItemUi.ParentCategory( + parent = dummySelectableCategoryUi(), + expanded = true, + children = listOf( + dummySelectableCategoryUi(), + dummySelectableCategoryUi(), + ) + ), + CategoryPickerItemUi.ParentCategory( + parent = dummySelectableCategoryUi(), + expanded = false, + children = listOf( + dummySelectableCategoryUi(), + ) + ), + CategoryPickerItemUi.CategoriesRow( + categories = listOf( + dummySelectableCategoryUi(), + dummySelectableCategoryUi(), + dummySelectableCategoryUi(), + dummySelectableCategoryUi(), + dummySelectableCategoryUi(), + ) + ), + CategoryPickerItemUi.ParentCategory( + parent = dummySelectableCategoryUi(), + expanded = true, + children = listOf( + dummySelectableCategoryUi(), + dummySelectableCategoryUi(), + dummySelectableCategoryUi(), + ) + ), + ) +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/pick/CategoryPickerState.kt b/core/ui/src/main/java/com/ivy/core/ui/category/pick/CategoryPickerState.kt new file mode 100644 index 0000000..d8356bd --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/pick/CategoryPickerState.kt @@ -0,0 +1,9 @@ +package com.ivy.core.ui.category.pick + +import androidx.compose.runtime.Immutable +import com.ivy.core.ui.category.pick.data.CategoryPickerItemUi + +@Immutable +data class CategoryPickerState( + val items: List +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/pick/CategoryPickerViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/category/pick/CategoryPickerViewModel.kt new file mode 100644 index 0000000..b071c10 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/pick/CategoryPickerViewModel.kt @@ -0,0 +1,66 @@ +package com.ivy.core.ui.category.pick + +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.pure.util.flattenLatest +import com.ivy.core.ui.category.pick.action.CategoryPickerItemsFlow +import com.ivy.core.ui.data.CategoryUi +import com.ivy.data.transaction.TransactionType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@HiltViewModel +class CategoryPickerViewModel @Inject constructor( + private val categoryPickerItemsFlow: CategoryPickerItemsFlow +) : SimpleFlowViewModel() { + override val initialUi = CategoryPickerState( + items = emptyList() + ) + + private val trnType = MutableStateFlow(null) + private val expandedParent = MutableStateFlow(null) + private val selectedCategory = MutableStateFlow(null) + + override val uiFlow: Flow = combine( + selectedCategory, expandedParent, trnType + ) { selectedCategory, expandedParent, trnType -> + categoryPickerItemsFlow( + CategoryPickerItemsFlow.Input( + selectedCategory = selectedCategory, + expandedParent = expandedParent, + trnType = trnType, + ) + ).map { + CategoryPickerState(items = it) + } + }.flattenLatest() + + + // region Event Handling + override suspend fun handleEvent(event: CategoryPickerEvent) = when (event) { + is CategoryPickerEvent.Initial -> handleInitial(event) + is CategoryPickerEvent.CategorySelected -> handleCategorySelected(event) + is CategoryPickerEvent.ExpandParent -> handleExpandParent(event) + CategoryPickerEvent.CollapseParent -> handleCollapseParent() + } + + private fun handleInitial(event: CategoryPickerEvent.Initial) { + trnType.value = event.trnType + } + + private fun handleCategorySelected(event: CategoryPickerEvent.CategorySelected) { + selectedCategory.value = event.category + } + + private fun handleExpandParent(event: CategoryPickerEvent.ExpandParent) { + expandedParent.value = event.parent.category + } + + private fun handleCollapseParent() { + expandedParent.value = null + } + // endregion +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/pick/action/CategoryPickerItemsFlow.kt b/core/ui/src/main/java/com/ivy/core/ui/category/pick/action/CategoryPickerItemsFlow.kt new file mode 100644 index 0000000..88e1c01 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/pick/action/CategoryPickerItemsFlow.kt @@ -0,0 +1,81 @@ +package com.ivy.core.ui.category.pick.action + +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.domain.action.category.CategoriesListFlow +import com.ivy.core.domain.action.data.CategoryListItem +import com.ivy.core.ui.action.mapping.MapCategoryUiAct +import com.ivy.core.ui.category.pick.data.CategoryPickerItemUi +import com.ivy.core.ui.category.pick.data.SelectableCategoryUi +import com.ivy.core.ui.data.CategoryUi +import com.ivy.data.transaction.TransactionType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + + +class CategoryPickerItemsFlow @Inject constructor( + private val categoriesListFlow: CategoriesListFlow, + private val mapCategoryUiAct: MapCategoryUiAct, +) : FlowAction>() { + data class Input( + val selectedCategory: CategoryUi?, + val expandedParent: CategoryUi?, + val trnType: TransactionType?, + ) + + override fun createFlow(input: Input): Flow> = + categoriesListFlow(CategoriesListFlow.Input(trnType = input.trnType)) + .map { items -> + items.mapNotNull { item -> + when (item) { + is CategoryListItem.Archived -> null + is CategoryListItem.CategoryHolder -> SelectableCategoryUi( + category = mapCategoryUiAct(item.category), + selected = item.category.id.toString() == input.selectedCategory?.id, + ) + is CategoryListItem.ParentCategory -> { + val hasSelectedChild = item.children.any { + it.id.toString() == input.selectedCategory?.id + } + CategoryPickerItemUi.ParentCategory( + parent = SelectableCategoryUi( + category = mapCategoryUiAct(item.parent), + selected = item.parent.id.toString() == input.selectedCategory?.id || + hasSelectedChild, + ), + expanded = input.expandedParent?.id == item.parent.id.toString() || + hasSelectedChild, + children = item.children.map { + SelectableCategoryUi( + category = mapCategoryUiAct(it), + selected = it.id.toString() == input.selectedCategory?.id, + ) + } + ) + } + } + } + }.map { data -> + val res = mutableListOf() + var catsRowAccumulator = mutableListOf() + + data.forEach { + when (it) { + is CategoryPickerItemUi.ParentCategory -> { + if (catsRowAccumulator.isNotEmpty()) { + res.add(CategoryPickerItemUi.CategoriesRow(catsRowAccumulator)) + catsRowAccumulator = mutableListOf() + } + res.add(it) + } + is SelectableCategoryUi -> catsRowAccumulator.add(it) + } + } + + if (catsRowAccumulator.isNotEmpty()) { + res.add(CategoryPickerItemUi.CategoriesRow(catsRowAccumulator)) + } + + res + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/pick/component/PickerCategoriesRow.kt b/core/ui/src/main/java/com/ivy/core/ui/category/pick/component/PickerCategoriesRow.kt new file mode 100644 index 0000000..0fa7fe3 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/pick/component/PickerCategoriesRow.kt @@ -0,0 +1,102 @@ +package com.ivy.core.ui.category.pick.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.R +import com.ivy.core.ui.category.pick.data.SelectableCategoryUi +import com.ivy.core.ui.category.pick.data.dummySelectableCategoryUi +import com.ivy.core.ui.data.dummyCategoryUi +import com.ivy.core.ui.data.icon.IconSize +import com.ivy.core.ui.data.icon.dummyIconUnknown +import com.ivy.core.ui.icon.ItemIcon +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.rememberContrast +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l3_ivyComponents.WrapContentRow +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.thenWhen + +@Composable +internal fun PickerCategoriesRow( + categories: List, + modifier: Modifier = Modifier, + onSelect: (SelectableCategoryUi) -> Unit, +) { + WrapContentRow( + modifier = modifier.padding(horizontal = 8.dp), + items = categories, + itemKey = { it.category.id }, + horizontalMarginBetweenItems = 8.dp, + verticalMarginBetweenRows = 8.dp, + ) { item -> + CategoryItem( + item = item, + onClick = { onSelect(item) }, + ) + } +} + +@Composable +private fun CategoryItem( + item: SelectableCategoryUi, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + val category = item.category + Row( + modifier = modifier + .clip(UI.shapes.rounded) + .thenWhen { + when (item.selected) { + true -> background(category.color, UI.shapes.rounded) + false -> border(1.dp, category.color, UI.shapes.rounded) + } + } + .clickable(onClick = onClick) + .padding(start = 8.dp, end = 16.dp) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val contrast = if (item.selected) + rememberContrast(category.color) else UI.colorsInverted.pure + ItemIcon( + itemIcon = category.icon, + size = IconSize.S, + tint = contrast + ) + SpacerHor(width = 4.dp) + B2(text = category.name, color = contrast) + } +} + + +@Preview +@Composable +private fun Preview() { + ComponentPreview { + PickerCategoriesRow( + categories = listOf( + dummySelectableCategoryUi( + category = dummyCategoryUi( + name = "Car", + icon = dummyIconUnknown(R.drawable.ic_vue_transport_car) + ) + ), + dummySelectableCategoryUi(selected = true), + dummySelectableCategoryUi(), + dummySelectableCategoryUi() + ), + onSelect = {} + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/pick/component/PickerParentCategory.kt b/core/ui/src/main/java/com/ivy/core/ui/category/pick/component/PickerParentCategory.kt new file mode 100644 index 0000000..0451676 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/pick/component/PickerParentCategory.kt @@ -0,0 +1,109 @@ +package com.ivy.core.ui.category.pick.component + +import androidx.compose.animation.* +import androidx.compose.foundation.background +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.category.pick.data.CategoryPickerItemUi +import com.ivy.core.ui.category.pick.data.SelectableCategoryUi +import com.ivy.core.ui.category.pick.data.dummySelectableCategoryUi +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.icon.IconSize +import com.ivy.core.ui.icon.ItemIcon +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.rememberContrast +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l1_buildingBlocks.DividerHor +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.thenWhen + +@Composable +internal fun PickerParentCategory( + item: CategoryPickerItemUi.ParentCategory, + onParentClick: () -> Unit, + onChildClick: (CategoryUi) -> Unit +) { + ParentCategoryItem(parent = item.parent, onClick = onParentClick) + AnimatedVisibility( + visible = item.expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column { + PickerCategoriesRow( + modifier = Modifier + .padding(top = 12.dp, bottom = 12.dp), + categories = item.children, + onSelect = { onChildClick(it.category) }, + ) + DividerHor() + } + } +} + +@Composable +private fun ParentCategoryItem( + parent: SelectableCategoryUi, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .clip(UI.shapes.rounded) + .thenWhen { + when (parent.selected) { + true -> background(parent.category.color, UI.shapes.rounded) + false -> border(1.dp, parent.category.color, UI.shapes.rounded) + } + } + .clickable(onClick = onClick) + .padding(start = 8.dp, end = 16.dp) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val contrast = if (parent.selected) + rememberContrast(parent.category.color) else UI.colorsInverted.pure + ItemIcon( + itemIcon = parent.category.icon, + size = IconSize.S, + tint = contrast + ) + SpacerHor(width = 8.dp) + B2(text = parent.category.name, color = contrast) + } +} + + +@Preview +@Composable +private fun Preview() { + ComponentPreview { + Column { + PickerParentCategory( + item = CategoryPickerItemUi.ParentCategory( + parent = dummySelectableCategoryUi(), + children = listOf( + dummySelectableCategoryUi(), + dummySelectableCategoryUi(), + dummySelectableCategoryUi(), + ), + expanded = true + ), + onParentClick = {}, + onChildClick = {} + ) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/pick/data/CategoryPickerItemUi.kt b/core/ui/src/main/java/com/ivy/core/ui/category/pick/data/CategoryPickerItemUi.kt new file mode 100644 index 0000000..116c8ed --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/pick/data/CategoryPickerItemUi.kt @@ -0,0 +1,16 @@ +package com.ivy.core.ui.category.pick.data + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface CategoryPickerItemUi { + data class CategoriesRow( + val categories: List + ) : CategoryPickerItemUi + + data class ParentCategory( + val parent: SelectableCategoryUi, + val expanded: Boolean, + val children: List + ) : CategoryPickerItemUi +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/pick/data/SelectableCategoryUi.kt b/core/ui/src/main/java/com/ivy/core/ui/category/pick/data/SelectableCategoryUi.kt new file mode 100644 index 0000000..a754235 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/pick/data/SelectableCategoryUi.kt @@ -0,0 +1,19 @@ +package com.ivy.core.ui.category.pick.data + +import androidx.compose.runtime.Immutable +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.dummyCategoryUi + +@Immutable +data class SelectableCategoryUi( + val category: CategoryUi, + val selected: Boolean +) + +fun dummySelectableCategoryUi( + category: CategoryUi = dummyCategoryUi(), + selected: Boolean = false +) = SelectableCategoryUi( + category = category, + selected = selected +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/pickparent/ParentCategoryPickerModal.kt b/core/ui/src/main/java/com/ivy/core/ui/category/pickparent/ParentCategoryPickerModal.kt new file mode 100644 index 0000000..8feed31 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/pickparent/ParentCategoryPickerModal.kt @@ -0,0 +1,165 @@ +package com.ivy.core.ui.category.pickparent + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.dummyCategoryUi +import com.ivy.core.ui.data.icon.IconSize +import com.ivy.core.ui.icon.ItemIcon +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.* +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Negative +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.hiltViewModelPreviewSafe +import com.ivy.design.util.thenWhen + +@Composable +fun BoxScope.ParentCategoryPickerModal( + modal: IvyModal, + selected: CategoryUi?, + level: Int = 1, + onPick: (CategoryUi?) -> Unit, +) { + val viewModel: ParentCategoryPickerViewModel? = hiltViewModelPreviewSafe() + val state = viewModel?.uiState?.collectAsState()?.value + ?: previewState() + + Modal( + modal = modal, + level = level, + actions = { + Negative(text = "Remove parent") { + onPick(null) + modal.hide() + } + } + ) { + LazyColumn( + modifier = Modifier.heightIn(min = 0.dp, max = 620.dp), + ) { + item { + Title(text = "Choose parent") + } + categoryItems( + items = state.categories, + selected = selected, + onSelect = { + onPick(it) + modal.hide() + } + ) + item { + SpacerVer(height = 48.dp) // last item spacer + } + } + } +} + +// region Folders +private fun LazyListScope.categoryItems( + items: List, + selected: CategoryUi?, + onSelect: (CategoryUi) -> Unit +) { + this.items( + items = items, + key = { "category_${it.id}" } + ) { category -> + SpacerVer(height = 12.dp) + CategoryItem( + category = category, + selected = category.id == selected?.id + ) { + onSelect(category) + } + } +} + +@Composable +internal fun CategoryItem( + category: CategoryUi, + selected: Boolean, + onClick: () -> Unit +) { + val dynamicContrast = rememberDynamicContrast(category.color) + val contrastColor = rememberContrast(category.color) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(UI.shapes.squared) + .thenWhen { + when (selected) { + true -> background(category.color, UI.shapes.squared) + .border(2.dp, dynamicContrast, UI.shapes.squared) + false -> border(2.dp, dynamicContrast, UI.shapes.squared) + } + } + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val color = if (selected) contrastColor else UI.colorsInverted.pure + ItemIcon( + itemIcon = category.icon, + size = IconSize.S, + tint = color, + ) + SpacerHor(width = 8.dp) + B2(text = category.name, color = color) + } +} +// endregion + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + ParentCategoryPickerModal( + modal = modal, + selected = dummyCategoryUi(id = "selected"), + onPick = {} + ) + } +} + +private fun previewState() = ParentCategoryPickerState( + categories = listOf( + dummyCategoryUi(id = "selected", name = "Category 1", color = Green), + dummyCategoryUi(name = "Category 2", color = Yellow), + dummyCategoryUi(name = "Category 3", color = Purple), + dummyCategoryUi(name = "Category 4", color = Purple), + dummyCategoryUi(name = "Category 5", color = Purple), + dummyCategoryUi(name = "Category 6", color = Purple), + dummyCategoryUi(name = "Category 7", color = Purple), + dummyCategoryUi(name = "Category 8", color = Purple), + dummyCategoryUi(name = "Category 9", color = Purple), + dummyCategoryUi(name = "Category 10", color = Purple), + dummyCategoryUi(name = "Category 11", color = Purple), + dummyCategoryUi(name = "Category 12", color = Purple), + ) +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/pickparent/ParentCategoryPickerState.kt b/core/ui/src/main/java/com/ivy/core/ui/category/pickparent/ParentCategoryPickerState.kt new file mode 100644 index 0000000..8fd6fb8 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/pickparent/ParentCategoryPickerState.kt @@ -0,0 +1,9 @@ +package com.ivy.core.ui.category.pickparent + +import androidx.compose.runtime.Immutable +import com.ivy.core.ui.data.CategoryUi + +@Immutable +internal data class ParentCategoryPickerState( + val categories: List +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/pickparent/ParentCategoryPickerViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/category/pickparent/ParentCategoryPickerViewModel.kt new file mode 100644 index 0000000..14c455e --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/pickparent/ParentCategoryPickerViewModel.kt @@ -0,0 +1,30 @@ +package com.ivy.core.ui.category.pickparent + +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.action.category.CategoriesFlow +import com.ivy.core.ui.action.mapping.MapCategoryUiAct +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@HiltViewModel +internal class ParentCategoryPickerViewModel @Inject constructor( + categoriesFlow: CategoriesFlow, + private val mapCategoryUiAct: MapCategoryUiAct, +) : SimpleFlowViewModel() { + override val initialUi = ParentCategoryPickerState(categories = emptyList()) + + override val uiFlow: Flow = + categoriesFlow().map { categories -> + ParentCategoryPickerState( + categories = categories + .filter { it.parentCategoryId == null } + .map { mapCategoryUiAct(it) } + ) + } + + // region Event Handling + override suspend fun handleEvent(event: Unit) {} + // endregion +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/reorder/ReorderCategoriesEvent.kt b/core/ui/src/main/java/com/ivy/core/ui/category/reorder/ReorderCategoriesEvent.kt new file mode 100644 index 0000000..ecfc274 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/reorder/ReorderCategoriesEvent.kt @@ -0,0 +1,9 @@ +package com.ivy.core.ui.category.reorder + +import com.ivy.core.ui.data.CategoryUi + +sealed interface ReorderCategoriesEvent { + data class Reorder( + val reordered: List + ) : ReorderCategoriesEvent +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/reorder/ReorderCategoriesModal.kt b/core/ui/src/main/java/com/ivy/core/ui/category/reorder/ReorderCategoriesModal.kt new file mode 100644 index 0000000..f95eb5d --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/reorder/ReorderCategoriesModal.kt @@ -0,0 +1,93 @@ +package com.ivy.core.ui.category.reorder + +import ReorderModal +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.dummyCategoryUi +import com.ivy.core.ui.data.icon.IconSize +import com.ivy.core.ui.icon.ItemIcon +import com.ivy.core.ui.uiStatePreviewSafe +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.* +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.previewModal +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.hiltViewModelPreviewSafe +import com.ivy.design.util.thenIf + +@Composable +fun BoxScope.ReorderCategoriesModal( + modal: IvyModal, + level: Int = 1, +) { + val viewModel: ReorderCategoriesViewModel? = hiltViewModelPreviewSafe() + val state = uiStatePreviewSafe(viewModel, preview = ::previewState) + + ReorderModal( + modal = modal, + level = level, + items = state.items, + onReorder = { + viewModel?.onEvent(ReorderCategoriesEvent.Reorder(it)) + } + ) { _, item -> + CategoryCard(category = item) + } +} + + +@Composable +private fun CategoryCard(category: CategoryUi) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) // margin top + .thenIf(category.hasParent) { + padding(start = 24.dp) + } + .padding(start = 8.dp, end = 16.dp) + .background(category.color, UI.shapes.rounded) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val contrast = rememberContrast(category.color) + ItemIcon(itemIcon = category.icon, size = IconSize.S, tint = contrast) + SpacerHor(width = 4.dp) + B2(text = category.name, color = contrast) + } +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = previewModal() + ReorderCategoriesModal(modal = modal) + } +} + +private fun previewState() = ReorderCategoriesStateUi( + items = listOf( + dummyCategoryUi("Category 1", color = Red), + dummyCategoryUi("Category 2", color = Green), + dummyCategoryUi("Category 3", hasParent = true), + dummyCategoryUi("Category 4", hasParent = true, color = Green3Dark), + dummyCategoryUi("Category 5", color = Blue), + dummyCategoryUi("Category 6", color = Yellow), + ), +) + +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/reorder/ReorderCategoriesStateUi.kt b/core/ui/src/main/java/com/ivy/core/ui/category/reorder/ReorderCategoriesStateUi.kt new file mode 100644 index 0000000..abbaa21 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/reorder/ReorderCategoriesStateUi.kt @@ -0,0 +1,7 @@ +package com.ivy.core.ui.category.reorder + +import com.ivy.core.ui.data.CategoryUi + +data class ReorderCategoriesStateUi( + val items: List +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/category/reorder/ReorderCategoriesViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/category/reorder/ReorderCategoriesViewModel.kt new file mode 100644 index 0000000..42372ba --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/category/reorder/ReorderCategoriesViewModel.kt @@ -0,0 +1,78 @@ +package com.ivy.core.ui.category.reorder + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.domain.FlowViewModel +import com.ivy.core.domain.action.category.CategoriesFlow +import com.ivy.core.domain.action.category.WriteCategoriesAct +import com.ivy.core.domain.action.data.Modify +import com.ivy.core.ui.action.mapping.MapCategoryUiAct +import com.ivy.core.ui.category.reorder.ReorderCategoriesViewModel.InternalState +import com.ivy.data.Sync +import com.ivy.data.SyncState +import com.ivy.data.category.Category +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@HiltViewModel +internal class ReorderCategoriesViewModel @Inject constructor( + categoriesFlow: CategoriesFlow, + private val mapCategoryUiAct: MapCategoryUiAct, + private val writeCategoriesAct: WriteCategoriesAct, + private val timeProvider: TimeProvider, +) : FlowViewModel() { + override val initialState = InternalState( + categories = emptyList(), + ) + + override val initialUi = ReorderCategoriesStateUi( + items = emptyList(), + ) + + override val stateFlow: Flow = categoriesFlow().map { categories -> + InternalState( + categories = categories, + ) + } + + override val uiFlow: Flow = stateFlow + .map { internalState -> + ReorderCategoriesStateUi( + items = internalState.categories.map { mapCategoryUiAct(it) }, + ) + } + + + // region Event handling + override suspend fun handleEvent(event: ReorderCategoriesEvent) = when (event) { + is ReorderCategoriesEvent.Reorder -> handleReorder(event) + } + + private suspend fun handleReorder(event: ReorderCategoriesEvent.Reorder) { + val categoriesMap = state.value.categories.associateBy { it.id.toString() } + + val reordered = event.reordered.mapIndexedNotNull { index, item -> + categoriesMap[item.id] + ?.copy( + orderNum = index.toDouble(), + sync = Sync( + state = SyncState.Syncing, + lastUpdated = timeProvider.timeNow(), + ) + ) + } + + val expectedCount = uiState.value.items.size + // verify no lost of data + if (reordered.size == expectedCount) { + writeCategoriesAct(Modify.saveMany(reordered)) + } + } + + // endregion + + data class InternalState( + val categories: List, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/color/ColorPickerButton.kt b/core/ui/src/main/java/com/ivy/core/ui/color/ColorPickerButton.kt new file mode 100644 index 0000000..0fb1837 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/color/ColorPickerButton.kt @@ -0,0 +1,81 @@ +package com.ivy.core.ui.color + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.rememberDynamicContrast +import com.ivy.design.l0_system.color.toHex +import com.ivy.design.l1_buildingBlocks.B1Second +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.util.ComponentPreview + +@Composable +fun ColorPickerButton( + colorPickerModal: IvyModal, + selectedColor: Color, + modifier: Modifier = Modifier, + paddingHorizontal: Dp = 24.dp, + paddingVertical: Dp = 24.dp, +) { + ColorButton( + color = selectedColor, + modifier = modifier, + paddingHorizontal = paddingHorizontal, + paddingVertical = paddingVertical, + ) { + colorPickerModal.show() + } +} + +@Composable +fun ColorButton( + color: Color, + modifier: Modifier = Modifier, + shape: Shape = UI.shapes.rounded, + paddingHorizontal: Dp = 24.dp, + paddingVertical: Dp = 24.dp, + onClick: () -> Unit, +) { + val colorHex = remember(color) { color.toHex() } + val dynamicContrast = rememberDynamicContrast(color) + B1Second( + modifier = modifier + .clip(shape) + .background(color, shape) + .border(width = 2.dp, color = dynamicContrast, shape) + .clickable(onClick = onClick) + .padding(horizontal = paddingHorizontal, vertical = paddingVertical), + text = "#$colorHex", + fontWeight = FontWeight.ExtraBold, + color = dynamicContrast, + textAlign = TextAlign.Start + ) +} + + +// region Previews +@Preview +@Composable +private fun Preview() { + ComponentPreview { + ColorPickerButton( + colorPickerModal = rememberIvyModal(), + selectedColor = UI.colors.primary + ) + } +} +// endregion diff --git a/core/ui/src/main/java/com/ivy/core/ui/color/picker/ColorPickerColors.kt b/core/ui/src/main/java/com/ivy/core/ui/color/picker/ColorPickerColors.kt new file mode 100644 index 0000000..5ca4d80 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/color/picker/ColorPickerColors.kt @@ -0,0 +1,23 @@ +package com.ivy.core.ui.color.picker + +import androidx.compose.ui.graphics.Color +import com.ivy.design.l0_system.color.* + + +internal fun colors(): List = listOf( + Purple, Purple1, Purple2, Blue, Blue2, Blue3, + Green, Green2, Green3, Green4, Yellow, + Orange, Orange2, Orange3, Red, Red2, Red3, +) + +internal fun lightColors(): List = listOf( + IvyLight, Purple1Light, Purple2Light, BlueLight, Blue2Light, Blue3Light, + GreenLight, Green2Light, Green3Light, Green4Light, YellowLight, + OrangeLight, Orange2Light, Orange3Light, RedLight, Red2Light, Red3Light, +) + +internal fun darkColors(): List = listOf( + IvyDark, Purple1Dark, Purple2Dark, BlueDark, Blue2Dark, Blue3Dark, + GreenDark, Green2Dark, Green3Dark, Green4Dark, YellowDark, + OrangeDark, Orange2Dark, Orange3Dark, RedDark, Red2Dark, Red3Dark, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/color/picker/ColorPickerEvent.kt b/core/ui/src/main/java/com/ivy/core/ui/color/picker/ColorPickerEvent.kt new file mode 100644 index 0000000..da3d3d4 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/color/picker/ColorPickerEvent.kt @@ -0,0 +1,3 @@ +package com.ivy.core.ui.color.picker + +internal sealed interface ColorPickerEvent \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/color/picker/ColorPickerModal.kt b/core/ui/src/main/java/com/ivy/core/ui/color/picker/ColorPickerModal.kt new file mode 100644 index 0000000..f299fa9 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/color/picker/ColorPickerModal.kt @@ -0,0 +1,285 @@ +package com.ivy.core.ui.color.picker + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +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.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.R +import com.ivy.core.ui.color.ColorButton +import com.ivy.core.ui.color.picker.ColorPickerViewModel.Companion.COLORS_PER_ROW +import com.ivy.core.ui.color.picker.custom.HexColorPickerModal +import com.ivy.core.ui.color.picker.data.ColorSectionUi +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.White +import com.ivy.design.l0_system.color.rememberDynamicContrast +import com.ivy.design.l1_buildingBlocks.* +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Choose +import com.ivy.design.l2_components.modal.components.Secondary +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l2_components.modal.scope.ModalActionsScope +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.hiltViewModelPreviewSafe +import com.ivy.design.util.thenIf + +private val colorItemSize = 48.dp + +@Composable +fun BoxScope.ColorPickerModal( + modal: IvyModal, + level: Int = 1, + initialColor: Color?, + onColorPicked: (Color) -> Unit, +) { + val viewModel: ColorPickerViewModel? = hiltViewModelPreviewSafe() + val state = viewModel?.uiState?.collectAsState()?.value ?: previewState() + + var selectedColor by remember(initialColor) { mutableStateOf(initialColor) } + val hexColorPickerModal = rememberIvyModal() + + Modal( + modal = modal, + level = level, + actions = { + ModalActions( + modal = modal, + hexColorPickerModal = hexColorPickerModal, + selectedColor = selectedColor, + onColorPicked = onColorPicked + ) + } + ) { + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + item(key = "color_picker_title") { + this@Modal.Title(text = stringResource(R.string.choose_color)) + } + selectedColorItem(selectedColor = selectedColor) { + // on click: + hexColorPickerModal.show() + } + sections( + sections = state.sections, + selectedColor = selectedColor, + onColorSelect = { + selectedColor = it + onColorPicked(it) + modal.hide() + } + ) + item(key = "color_picker_last_spacer") { SpacerVer(height = 48.dp) } + } + } + + HexColorPickerModal( + modal = hexColorPickerModal, + initialColor = selectedColor, + onColorPicked = { + selectedColor = it + it.let(onColorPicked) + modal.hide() + } + ) +} + +// region ModalActions +@Composable +private fun ModalActionsScope.ModalActions( + modal: IvyModal, + hexColorPickerModal: IvyModal, + selectedColor: Color?, + onColorPicked: (Color) -> Unit +) { + Secondary( + text = null, + icon = R.drawable.outline_color_lens_24 + ) { + hexColorPickerModal.show() + } + SpacerHor(width = 8.dp) + Choose { + selectedColor?.let(onColorPicked) + modal.hide() + } +} +// endregion + +// region Picked Color +private fun LazyListScope.selectedColorItem( + selectedColor: Color?, + onClick: () -> Unit +) { + if (selectedColor != null) { + item(key = "selected_color_${selectedColor.value}") { + SpacerVer(height = 24.dp) + ColorButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + color = selectedColor, + onClick = onClick, + ) + } + } +} + +// endregion + +// region Sections +private fun LazyListScope.sections( + sections: List, + selectedColor: Color?, + onColorSelect: (Color) -> Unit +) { + sections.forEach { + section( + section = it, + selectedColor = selectedColor, + onColorSelect = onColorSelect, + ) + } +} + +private fun LazyListScope.section( + section: ColorSectionUi, + selectedColor: Color?, + onColorSelect: (Color) -> Unit +) { + item(key = "section_${section.name}_${section.colorRows.size}") { + SpacerVer(height = 24.dp) + SectionDivider(title = section.name) + SpacerVer(height = 12.dp) + } + items( + items = section.colorRows, + key = { "color_row_${it.first().value}" } + ) { colorRow -> + ColorsRow( + colors = colorRow, + selectedColor = selectedColor, + onColorSelect = onColorSelect + ) + SpacerVer(height = 12.dp) + } +} + +@Composable +private fun SectionDivider(title: String) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + DividerW() + SpacerHor(width = 16.dp) + B1(text = title) + SpacerHor(width = 16.dp) + DividerW() + } +} +// endregion + +// region ColorsRow +@Composable +private fun ColorsRow( + colors: List, + selectedColor: Color?, + onColorSelect: (Color) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + SpacerWeight(weight = 1f) + for (color in colors) { + key("color_item_${color.value}") { + ColorItem( + color = color, + selected = selectedColor == color, + ) { + // on click: + onColorSelect(it) + } + SpacerWeight(weight = 1f) + } + } + MissingColorsInRowSpace(missingColors = COLORS_PER_ROW - colors.size) + } +} + +@Composable +private fun RowScope.MissingColorsInRowSpace( + missingColors: Int +) { + if (missingColors > 0) { + SpacerHor(width = colorItemSize * missingColors) + SpacerWeight(weight = 1f * missingColors) + } +} +// endregion + +// region ColorItem +@Composable +private fun ColorItem( + color: Color, + selected: Boolean, + onClick: (Color) -> Unit +) { + Shape(modifier = Modifier + .clip(UI.shapes.circle) + .size(colorItemSize) + .background(color, UI.shapes.circle) + .thenIf(selected) { + border( + width = 4.dp, + color = rememberDynamicContrast(color), + shape = UI.shapes.circle + ).border( + width = 5.dp, + color = White, + shape = UI.shapes.circle + ) + } + .clickable(onClick = { onClick(color) }) + .testTag("color_item_${color.value}") + ) +} +// endregion + + +// region Previews +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + ColorPickerModal( + modal = modal, + initialColor = UI.colors.primary, + onColorPicked = {} + ) + } +} + +private fun previewState() = ColorPickerState( + sections = emptyList() // TODO: Add Preview state +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/color/picker/ColorPickerState.kt b/core/ui/src/main/java/com/ivy/core/ui/color/picker/ColorPickerState.kt new file mode 100644 index 0000000..0495b9d --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/color/picker/ColorPickerState.kt @@ -0,0 +1,9 @@ +package com.ivy.core.ui.color.picker + +import androidx.compose.runtime.Immutable +import com.ivy.core.ui.color.picker.data.ColorSectionUi + +@Immutable +internal data class ColorPickerState( + val sections: List +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/color/picker/ColorPickerViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/color/picker/ColorPickerViewModel.kt new file mode 100644 index 0000000..fc95b77 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/color/picker/ColorPickerViewModel.kt @@ -0,0 +1,54 @@ +package com.ivy.core.ui.color.picker + +import androidx.compose.ui.graphics.Color +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.pure.ui.groupByRows +import com.ivy.core.ui.color.picker.data.ColorSectionUi +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@HiltViewModel +internal class ColorPickerViewModel @Inject constructor() : + SimpleFlowViewModel() { + companion object { + const val COLORS_PER_ROW = 5 + } + + override val initialUi = ColorPickerState( + sections = listOf() + ) + + override val uiFlow: Flow = colorSectionsFlow().map { sections -> + ColorPickerState( + sections = sections, + ) + } + + private fun colorSectionsFlow(): Flow> = flowOf( + listOf( + ColorSectionUi( + name = "Primary", + colorRows = colorRows(colors()) + ), + ColorSectionUi( + name = "Light", + colorRows = colorRows(lightColors()) + ), + ColorSectionUi( + name = "Dark", + colorRows = colorRows(darkColors()) + ), + ) + ) + + private fun colorRows(colors: List): List> = + groupByRows(colors, itemsPerRow = COLORS_PER_ROW) + + + // region Event Handling + override suspend fun handleEvent(event: ColorPickerEvent) {} + // endregion +} diff --git a/core/ui/src/main/java/com/ivy/core/ui/color/picker/custom/HexColorPickerEvent.kt b/core/ui/src/main/java/com/ivy/core/ui/color/picker/custom/HexColorPickerEvent.kt new file mode 100644 index 0000000..4d28946 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/color/picker/custom/HexColorPickerEvent.kt @@ -0,0 +1,8 @@ +package com.ivy.core.ui.color.picker.custom + +import androidx.compose.ui.graphics.Color + +sealed interface HexColorPickerEvent { + data class SetColor(val color: Color) : HexColorPickerEvent + data class Hex(val hex: String) : HexColorPickerEvent +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/color/picker/custom/HexColorPickerModal.kt b/core/ui/src/main/java/com/ivy/core/ui/color/picker/custom/HexColorPickerModal.kt new file mode 100644 index 0000000..9a32cd4 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/color/picker/custom/HexColorPickerModal.kt @@ -0,0 +1,172 @@ +package com.ivy.core.ui.color.picker.custom + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.Purple +import com.ivy.design.l0_system.color.rememberDynamicContrast +import com.ivy.design.l0_system.color.toHex +import com.ivy.design.l1_buildingBlocks.B1Second +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.input.InputFieldType +import com.ivy.design.l2_components.input.InputFieldTypography +import com.ivy.design.l2_components.input.IvyInputField +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Choose +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.hiltViewModelPreviewSafe + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun BoxScope.HexColorPickerModal( + modal: IvyModal, + initialColor: Color?, + level: Int = 3, + onColorPicked: (Color) -> Unit +) { + val viewModel: HexColorPickerViewModel? = hiltViewModelPreviewSafe() + val state = viewModel?.uiState?.collectAsState()?.value ?: previewState() + + if (initialColor != null) { + LaunchedEffect(initialColor) { + viewModel?.onEvent(HexColorPickerEvent.SetColor(initialColor)) + } + } + + val keyboardController = LocalSoftwareKeyboardController.current + Modal( + modal = modal, + level = level, + actions = { + Choose { + keyboardController?.hide() + state.color?.let(onColorPicked) + modal.hide() + } + } + ) { + Title(text = "Custom Color") + SpacerVer(height = 24.dp) + HexInput( + initialHex = state.hex, + isError = state.color == null, + feeling = state.color?.let(Feeling::Custom) ?: Feeling.Positive, + onHexChange = { + viewModel?.onEvent(HexColorPickerEvent.Hex(it)) + } + ) + SpacerVer(height = 24.dp) + PickedColor( + modifier = Modifier.align(Alignment.CenterHorizontally), + color = state.color, + hex = state.hex, + ) + SpacerVer(height = 48.dp) + } +} + +// region Hex input field +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun HexInput( + initialHex: String, + isError: Boolean, + feeling: Feeling, + onHexChange: (String) -> Unit, +) { + val keyboardController = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + keyboardController?.show() + } + IvyInputField( + modifier = Modifier + .focusRequester(focusRequester) + .fillMaxWidth() + .padding(horizontal = 24.dp), + isError = isError, + feeling = feeling, + type = InputFieldType.SingleLine, + typography = InputFieldTypography.Secondary, + initialValue = initialHex, + keyboardCapitalization = KeyboardCapitalization.Characters, + placeholder = "#RRGGBB (or #AARRGGBB)", + onValueChange = onHexChange + ) +} +// endregion + +// region PickedColor +@Composable +private fun PickedColor( + color: Color?, + hex: String, + modifier: Modifier = Modifier, +) { + val dynamicContrast = color?.let { rememberDynamicContrast(color) } + val textColor = dynamicContrast ?: UI.colors.red + Box( + modifier = modifier + .size(168.dp) + .background(color ?: UI.colors.pure, UI.shapes.rounded) + .border( + width = 4.dp, + color = textColor, + shape = UI.shapes.rounded + ), + ) { + B1Second( + modifier = Modifier.align(Alignment.Center), + text = if (color != null) hex else "Invalid #HEX", + color = textColor, + fontWeight = FontWeight.ExtraBold, + textAlign = TextAlign.Center + ) + } + +} +// endregion + + +// region Previews +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + HexColorPickerModal( + modal = modal, + initialColor = Purple, + onColorPicked = {} + ) + } +} + +private fun previewState() = HexColorPickerState( + hex = "#${Purple.toHex()}", + color = Purple +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/color/picker/custom/HexColorPickerState.kt b/core/ui/src/main/java/com/ivy/core/ui/color/picker/custom/HexColorPickerState.kt new file mode 100644 index 0000000..3b68c4c --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/color/picker/custom/HexColorPickerState.kt @@ -0,0 +1,10 @@ +package com.ivy.core.ui.color.picker.custom + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +@Immutable +data class HexColorPickerState( + val hex: String, + val color: Color? +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/color/picker/custom/HexColorPickerViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/color/picker/custom/HexColorPickerViewModel.kt new file mode 100644 index 0000000..aba27b5 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/color/picker/custom/HexColorPickerViewModel.kt @@ -0,0 +1,44 @@ +package com.ivy.core.ui.color.picker.custom + +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.design.l0_system.color.fromHex +import com.ivy.design.l0_system.color.toHex +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@HiltViewModel +internal class HexColorPickerViewModel @Inject constructor( +) : SimpleFlowViewModel() { + override val initialUi = HexColorPickerState( + hex = "", + color = null, + ) + + private val hexFlow = MutableStateFlow("") + + override val uiFlow: Flow = hexFlow.map { hex -> + HexColorPickerState( + hex = "#$hex".uppercase(), + color = fromHex(hex) + ) + } + + + // region Event Handling + override suspend fun handleEvent(event: HexColorPickerEvent) = when (event) { + is HexColorPickerEvent.Hex -> handleHex(event) + is HexColorPickerEvent.SetColor -> setColor(event) + } + + private fun setColor(event: HexColorPickerEvent.SetColor) { + hexFlow.value = event.color.toHex() + } + + private fun handleHex(event: HexColorPickerEvent.Hex) { + hexFlow.value = event.hex.replace("#", "") + } + // endregion +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/color/picker/data/ColorSectionUi.kt b/core/ui/src/main/java/com/ivy/core/ui/color/picker/data/ColorSectionUi.kt new file mode 100644 index 0000000..2947eeb --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/color/picker/data/ColorSectionUi.kt @@ -0,0 +1,10 @@ +package com.ivy.core.ui.color.picker.data + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +@Immutable +data class ColorSectionUi( + val name: String, + val colorRows: List> +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/component/Badge.kt b/core/ui/src/main/java/com/ivy/core/ui/component/Badge.kt new file mode 100644 index 0000000..8f046d1 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/component/Badge.kt @@ -0,0 +1,78 @@ +package com.ivy.core.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.R +import com.ivy.core.ui.data.icon.IconSize +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.core.ui.icon.ItemIcon +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.Blue2Dark +import com.ivy.design.l0_system.color.rememberContrast +import com.ivy.design.l1_buildingBlocks.Caption +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.thenIf + +// TODO: Consider unifying and merging with AccountButton + +@Composable +fun BadgeComponent( + text: String, + icon: ItemIcon, + background: Color, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null +) { + Row( + modifier = modifier + .background(background, UI.shapes.fullyRounded) + .thenIf(onClick != null) { + clip(UI.shapes.fullyRounded) + .clickable(onClick = onClick!!) + } + .padding(start = 8.dp, end = 18.dp) + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val contrastColor = rememberContrast(background) + ItemIcon( + itemIcon = icon, + size = IconSize.S, + tint = contrastColor, + ) + Caption( + modifier = Modifier + .padding(start = 4.dp) + .widthIn(min = 0.dp, max = 120.dp), + text = text, + color = contrastColor, + fontWeight = FontWeight.ExtraBold + ) + } +} + +@Preview +@Composable +private fun Preview_Badge() { + ComponentPreview { + BadgeComponent( + text = "Text", + icon = ItemIcon.Unknown( + icon = R.drawable.ic_vue_transport_car, + iconId = null + ), + background = Blue2Dark + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/component/ItemIconNameRow.kt b/core/ui/src/main/java/com/ivy/core/ui/component/ItemIconNameRow.kt new file mode 100644 index 0000000..da51170 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/component/ItemIconNameRow.kt @@ -0,0 +1,82 @@ +package com.ivy.core.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.R +import com.ivy.core.ui.data.icon.IconSize +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.core.ui.data.icon.dummyIconSized +import com.ivy.core.ui.icon.ItemIcon +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.rememberDynamicContrast +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.util.ComponentPreview + +@Composable +fun ItemIconNameRow( + icon: ItemIcon, + color: Color, + initialName: String, + nameInputHint: String, + autoFocusInput: Boolean, + modifier: Modifier = Modifier, + onPickIcon: () -> Unit, + onNameChange: (String) -> Unit +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ItemIcon( + modifier = Modifier + .clip(UI.shapes.circle) + .background(color, UI.shapes.circle) + .clickable(onClick = onPickIcon) + .padding(all = 4.dp), + itemIcon = icon, + size = IconSize.M, + tint = rememberDynamicContrast(color) + ) + SpacerHor(width = 8.dp) + ItemNameInput( + modifier = Modifier.weight(1f), + initialName = initialName, + hint = nameInputHint, + feeling = Feeling.Custom(color), + autoFocus = autoFocusInput, + onNameChange = onNameChange + ) + } +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + ComponentPreview { + ItemIconNameRow( + icon = dummyIconSized(R.drawable.ic_custom_account_m), + color = UI.colors.primary, + initialName = "", + nameInputHint = "New account", + autoFocusInput = false, + onPickIcon = {}, + onNameChange = {}, + ) + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/component/ItemNameInput.kt b/core/ui/src/main/java/com/ivy/core/ui/component/ItemNameInput.kt new file mode 100644 index 0000000..dac14cc --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/component/ItemNameInput.kt @@ -0,0 +1,81 @@ +package com.ivy.core.ui.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.focus.focusRequester +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.ivy.core.ui.R +import com.ivy.design.l0_system.UI +import com.ivy.design.l2_components.input.InputFieldType +import com.ivy.design.l2_components.input.IvyInputField +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.util.ComponentPreview + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun ItemNameInput( + initialName: String, + modifier: Modifier = Modifier, + feeling: Feeling, + hint: String, + autoFocus: Boolean, + onNameChange: (String) -> Unit, +) { + val focus = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(autoFocus) { + if (autoFocus) { + focus.requestFocus() + keyboardController?.show() + } + } + + IvyInputField( + modifier = modifier + .focusRequester(focus), + type = InputFieldType.SingleLine, + initialValue = initialName, + shape = UI.shapes.fullyRounded, + feeling = feeling, + placeholder = hint, + onValueChange = onNameChange + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview_Empty() { + ComponentPreview { + ItemNameInput( + initialName = "", + hint = stringResource(R.string.account_name), + feeling = Feeling.Positive, + autoFocus = false, + onNameChange = {} + ) + } +} + +@Preview +@Composable +private fun Preview_Filled() { + ComponentPreview { + ItemNameInput( + initialName = "Cash", + hint = stringResource(R.string.account_name), + feeling = Feeling.Positive, + autoFocus = false, + onNameChange = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/component/ScreenBottomBar.kt b/core/ui/src/main/java/com/ivy/core/ui/component/ScreenBottomBar.kt new file mode 100644 index 0000000..4b0546c --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/component/ScreenBottomBar.kt @@ -0,0 +1,86 @@ +package com.ivy.core.ui.component + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.ivy.core.domain.HandlerViewModel +import com.ivy.design.l1_buildingBlocks.SpacerWeight +import com.ivy.design.l3_ivyComponents.BackButton +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.hiltViewModelPreviewSafe +import com.ivy.navigation.Navigator +import com.ivy.resources.R +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@Composable +fun BoxScope.ScreenBottomBar( + modifier: Modifier = Modifier, + actions: @Composable () -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .systemBarsPadding() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .zIndex(500f), + verticalAlignment = Alignment.CenterVertically + ) { + val viewModel: BottomBarViewModel? = hiltViewModelPreviewSafe() + BackButton( + modifier = Modifier + ) { + viewModel?.onEvent(BottomBarEvent.Back) + } + SpacerWeight(weight = 1f) + actions() + } +} + +private sealed interface BottomBarEvent { + object Back : BottomBarEvent +} + +@HiltViewModel +private class BottomBarViewModel @Inject constructor( + private val navigator: Navigator, +) : HandlerViewModel() { + override suspend fun handleEvent(event: BottomBarEvent) = when (event) { + BottomBarEvent.Back -> handleBack() + } + + private fun handleBack() { + navigator.back() + } +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + ScreenBottomBar { + IvyButton( + size = ButtonSize.Small, + visibility = Visibility.High, + feeling = Feeling.Positive, + text = "New category", + icon = R.drawable.ic_round_add_24 + ) { + + } + } + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/component/SelectableItem.kt b/core/ui/src/main/java/com/ivy/core/ui/component/SelectableItem.kt new file mode 100644 index 0000000..16aefd6 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/component/SelectableItem.kt @@ -0,0 +1,193 @@ +package com.ivy.core.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.R +import com.ivy.core.ui.data.icon.IconSize +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.core.ui.data.icon.dummyIconUnknown +import com.ivy.core.ui.icon.ItemIcon +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.Purple +import com.ivy.design.l0_system.color.rememberContrast +import com.ivy.design.l0_system.color.rememberDynamicContrast +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l1_buildingBlocks.Caption +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.thenIf +import com.ivy.design.util.thenWhen + +@Composable +fun SelectableItem( + name: String, + icon: ItemIcon, + color: Color, + selected: Boolean, + deselectButton: Boolean, + modifier: Modifier = Modifier, + onSelect: () -> Unit, + onDeselect: () -> Unit, +) { + val dynamicContrast = rememberDynamicContrast(color) + Row( + modifier = modifier + .clip(UI.shapes.fullyRounded) + .thenWhen { + when (selected) { + true -> background(color, UI.shapes.fullyRounded) + .border(2.dp, dynamicContrast, UI.shapes.fullyRounded) + false -> border(1.dp, color, UI.shapes.fullyRounded) + } + } + .clickable(onClick = onSelect) + .thenIf(selected && !deselectButton) { + padding(vertical = 8.dp) + .padding(end = 24.dp) + }, + verticalAlignment = Alignment.CenterVertically + ) { + when (selected) { + true -> SelectedContent( + name = name, + icon = icon, + color = color, + deselectButton = deselectButton, + onDeselect = onDeselect + ) + false -> Content( + name = name, + icon = icon, + color = color + ) + } + } +} + +@Suppress("unused") +@Composable +private fun RowScope.SelectedContent( + name: String, + icon: ItemIcon, + color: Color, + deselectButton: Boolean, + onDeselect: () -> Unit +) { + SpacerHor(width = 12.dp) + val contrastColor = rememberContrast(color) + ItemIcon( + itemIcon = icon, + size = IconSize.S, + tint = contrastColor, + ) + SpacerHor(width = 8.dp) + B2( + text = name, + color = contrastColor, + fontWeight = FontWeight.ExtraBold + ) + if (deselectButton) { + SpacerHor(width = 12.dp) + IvyButton( + modifier = Modifier.padding(all = 4.dp), + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = Feeling.Negative, + text = null, + icon = R.drawable.round_remove_24, + onClick = onDeselect + ) + } +} + +@Suppress("unused") +@Composable +private fun RowScope.Content( + name: String, + color: Color, + icon: ItemIcon, +) { + ItemIcon( + modifier = Modifier + .padding(vertical = 8.dp) + .padding(start = 8.dp), + itemIcon = icon, + size = IconSize.S, + tint = UI.colorsInverted.pure, + ) + Caption( + modifier = Modifier.padding( + start = 8.dp, end = 16.dp + ), + text = name, + color = UI.colorsInverted.pure, + fontWeight = FontWeight.ExtraBold + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview_Selected() { + ComponentPreview { + SelectableItem( + name = "Account", + icon = dummyIconUnknown(R.drawable.ic_vue_building_bank), + color = Purple, + selected = true, + deselectButton = true, + onSelect = {}, + onDeselect = {} + ) + } +} + +@Preview +@Composable +private fun Preview_Selected_noDeselectButton() { + ComponentPreview { + SelectableItem( + name = "Account", + icon = dummyIconUnknown(R.drawable.ic_vue_building_bank), + color = Purple, + selected = true, + deselectButton = false, + onSelect = {}, + onDeselect = {} + ) + } +} + +@Preview +@Composable +private fun Preview_Deselected() { + ComponentPreview { + SelectableItem( + name = "Account", + icon = dummyIconUnknown(R.drawable.ic_vue_building_bank), + color = Purple, + selected = false, + deselectButton = true, + onSelect = {}, + onDeselect = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/currency/CurrencyModalEvent.kt b/core/ui/src/main/java/com/ivy/core/ui/currency/CurrencyModalEvent.kt new file mode 100644 index 0000000..11441dc --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/currency/CurrencyModalEvent.kt @@ -0,0 +1,11 @@ +package com.ivy.core.ui.currency + +import com.ivy.core.ui.currency.data.CurrencyUi +import com.ivy.data.CurrencyCode + +internal sealed interface CurrencyModalEvent { + data class Search(val query: String) : CurrencyModalEvent + data class SelectCurrency(val currencyUi: CurrencyUi) : CurrencyModalEvent + data class SelectCurrencyCode(val currencyCode: CurrencyCode) : CurrencyModalEvent + data class Initial(val initialCurrency: CurrencyCode) : CurrencyModalEvent +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/currency/CurrencyModalState.kt b/core/ui/src/main/java/com/ivy/core/ui/currency/CurrencyModalState.kt new file mode 100644 index 0000000..146cca6 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/currency/CurrencyModalState.kt @@ -0,0 +1,14 @@ +package com.ivy.core.ui.currency + +import androidx.compose.runtime.Immutable +import com.ivy.core.ui.currency.data.CurrencyListItem +import com.ivy.core.ui.currency.data.CurrencyUi +import com.ivy.data.CurrencyCode + +@Immutable +internal data class CurrencyModalState( + val items: List, + val suggested: List, + val selectedCurrency: CurrencyUi?, + val searchQuery: String +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/currency/CurrencyPickerModal.kt b/core/ui/src/main/java/com/ivy/core/ui/currency/CurrencyPickerModal.kt new file mode 100644 index 0000000..a3c703f --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/currency/CurrencyPickerModal.kt @@ -0,0 +1,358 @@ +package com.ivy.core.ui.currency + +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.currency.data.CurrencyListItem +import com.ivy.core.ui.currency.data.CurrencyUi +import com.ivy.data.CurrencyCode +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.rememberContrast +import com.ivy.design.l1_buildingBlocks.* +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Choose +import com.ivy.design.l2_components.modal.components.Search +import com.ivy.design.l2_components.modal.components.SearchButton +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.WrapContentRow +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.hiltViewModelPreviewSafe +import com.ivy.resources.R + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun BoxScope.CurrencyPickerModal( + modal: IvyModal, + level: Int = 2, + initialCurrency: CurrencyCode?, + onCurrencyPick: (CurrencyCode) -> Unit, +) { + val viewModel: CurrencyPickerModalViewModel? = hiltViewModelPreviewSafe() + val state = viewModel?.uiState?.collectAsState()?.value ?: previewState() + + LaunchedEffect(initialCurrency) { + if (initialCurrency != null) { + viewModel?.onEvent(CurrencyModalEvent.Initial(initialCurrency = initialCurrency)) + } + } + + var searchBarVisible by remember { mutableStateOf(false) } + + val keyboardController = LocalSoftwareKeyboardController.current + val resetSearch = { + keyboardController?.hide() + viewModel?.onEvent(CurrencyModalEvent.Search(query = "")) + searchBarVisible = false + } + + Modal( + modal = modal, + level = level, + actions = { + SearchButton(searchBarVisible = searchBarVisible) { + if (searchBarVisible) resetSearch() else searchBarVisible = true + } + SpacerHor(width = 8.dp) + Choose { + keyboardController?.hide() + resetSearch() + modal.hide() + state.selectedCurrency?.code?.let(onCurrencyPick) + } + } + ) { + Search( + searchBarVisible = searchBarVisible, + initialSearchQuery = state.searchQuery, + searchHint = "Search (e.g. EUR, USD, BTC)", + resetSearch = resetSearch, + onSearch = { viewModel?.onEvent(CurrencyModalEvent.Search(it)) }, + overlay = { + Suggested( + suggested = state.suggested, + searchBarVisible = searchBarVisible, + selectedCurrency = state.selectedCurrency, + onClick = { + resetSearch() + modal.hide() + viewModel?.onEvent(CurrencyModalEvent.SelectCurrencyCode(it)) + onCurrencyPick(it) + } + ) + } + ) { + item(key = "cp_header") { + Title(text = stringResource(R.string.choose_currency)) + SpacerVer(height = 12.dp) + } + item(key = "cp_selected_currency_${state.selectedCurrency?.code}") { + SelectedCurrency(selectedCurrency = state.selectedCurrency) + } + currencies( + items = state.items, + selectedCurrency = state.selectedCurrency, + onCurrencySelect = { + resetSearch() + modal.hide() + viewModel?.onEvent(CurrencyModalEvent.SelectCurrency(it)) + onCurrencyPick(it.code) + } + ) + item(key = "cp_last_item_spacer") { + // last item spacer + SpacerVer(height = 24.dp) + } + } + } +} + +// region Currencies list +private fun LazyListScope.currencies( + items: List, + selectedCurrency: CurrencyUi?, + onCurrencySelect: (CurrencyUi) -> Unit +) { + items( + items = items, + key = { + when (it) { + is CurrencyListItem.Currency -> "${it.currency.code}${it.currency.name}" + is CurrencyListItem.SectionDivider -> "divider_${it.name}" + } + } + ) { item -> + when (item) { + is CurrencyListItem.Currency -> { + SpacerVer(height = 12.dp) + CurrencyItem( + currency = item.currency, + selected = item.currency == selectedCurrency, + onClick = onCurrencySelect + ) + } + is CurrencyListItem.SectionDivider -> { + SpacerVer(height = 24.dp) + SectionDivider(divider = item) + } + } + } +} + +@Composable +private fun SectionDivider( + divider: CurrencyListItem.SectionDivider +) { + Caption( + modifier = Modifier.padding(start = 32.dp), + text = divider.name, + fontWeight = FontWeight.SemiBold + ) +} + +@Composable +private fun CurrencyItem( + currency: CurrencyUi, + selected: Boolean, + onClick: (CurrencyUi) -> Unit, +) { + val bgColor = if (selected) UI.colors.primary else UI.colors.medium + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(UI.shapes.rounded) + .background(bgColor, UI.shapes.rounded) + .clickable { onClick(currency) } + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val textColor = rememberContrast(bgColor) + B1Second(text = currency.code, fontWeight = FontWeight.ExtraBold, color = textColor) + B2( + modifier = Modifier + .weight(1f) + .padding(start = 24.dp), + text = currency.name, + fontWeight = FontWeight.SemiBold, + color = textColor, + textAlign = TextAlign.End, + overflow = TextOverflow.Ellipsis, + ) + } +} +// endregion + +// region Selected currency +@Composable +private fun SelectedCurrency( + selectedCurrency: CurrencyUi? +) { + if (selectedCurrency != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .background(UI.colors.primary, UI.shapes.squared) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val contrast = rememberContrast(UI.colors.primary) + Column( + modifier = Modifier + .weight(1f) + .padding(end = 16.dp) + ) { + B2( + text = selectedCurrency.code, + color = contrast, + fontWeight = FontWeight.Normal + ) + B1Second( + modifier = Modifier.fillMaxWidth(), + text = selectedCurrency.name, + color = contrast, + fontWeight = FontWeight.ExtraBold, + textAlign = TextAlign.Start, + overflow = TextOverflow.Ellipsis, + ) + } + IconRes(icon = R.drawable.ic_round_check_24, tint = contrast) + SpacerHor(width = 4.dp) + B2( + text = stringResource(R.string.selected), + color = contrast + ) + } + } +} +// endregion + +// region Suggested currencies +@Composable +private fun BoxScope.Suggested( + suggested: List, + searchBarVisible: Boolean, + selectedCurrency: CurrencyUi?, + onClick: (CurrencyCode) -> Unit, +) { + if (suggested.isEmpty()) return + + AnimatedVisibility( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + visible = !searchBarVisible, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(UI.colors.pure, UI.shapes.roundedTop) + .padding(bottom = 4.dp) + .border(1.dp, UI.colors.neutral, UI.shapes.roundedTop) + .padding(top = 12.dp, bottom = 16.dp) + ) { + B1( + modifier = Modifier.padding(start = 24.dp), + text = "Suggested", + ) + SpacerVer(height = 12.dp) + WrapContentRow( + modifier = Modifier + .padding(horizontal = 8.dp), + items = suggested, + itemKey = { "suggested_$it" } + ) { currency -> + SuggestedCurrencyItem( + currencyCode = currency, + selected = currency == selectedCurrency?.code + ) { + onClick(currency) + } + } + } + } +} + +@Composable +private fun SuggestedCurrencyItem( + currencyCode: CurrencyCode, + selected: Boolean, + onClick: () -> Unit +) { + IvyButton( + size = ButtonSize.Small, + visibility = if (selected) Visibility.High else Visibility.Medium, + feeling = Feeling.Positive, + text = currencyCode, + icon = null, + onClick = onClick, + ) +} +// endregion + +// region Previews +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + CurrencyPickerModal( + modal = modal, + initialCurrency = null, + onCurrencyPick = {} + ) + } +} + +private fun previewState() = CurrencyModalState( + items = listOf( + CurrencyListItem.SectionDivider(name = "A"), + CurrencyListItem.Currency(CurrencyUi("BGN", "Bulgarian Lev")), + CurrencyListItem.Currency(CurrencyUi("USD", "US Dollar")), + CurrencyListItem.Currency(CurrencyUi("EUR", "Euro")), + CurrencyListItem.SectionDivider(name = "Crypto"), + CurrencyListItem.Currency(CurrencyUi("BTC", "Bitcoin")), + CurrencyListItem.SectionDivider(name = "Dummy"), + CurrencyListItem.Currency(CurrencyUi("DMY1", "Dummy")), + CurrencyListItem.Currency(CurrencyUi("DMY2", "Dummy")), + CurrencyListItem.Currency(CurrencyUi("DMY3", "Dummy")), + CurrencyListItem.Currency(CurrencyUi("DMY4", "Dummy")), + CurrencyListItem.Currency(CurrencyUi("DMY5", "Dummy")), + ), + suggested = listOf( + "BGN", + "ADA", + "EUR", + "USD", + "GBP", + "INR", + ), + selectedCurrency = CurrencyUi("BGN", "Bulgarian Lev"), + searchQuery = "" +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/currency/CurrencyPickerModalViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/currency/CurrencyPickerModalViewModel.kt new file mode 100644 index 0000000..0c18d3b --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/currency/CurrencyPickerModalViewModel.kt @@ -0,0 +1,121 @@ +package com.ivy.core.ui.currency + +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.action.account.AccountsFlow +import com.ivy.core.ui.currency.data.CurrencyListItem +import com.ivy.core.ui.currency.data.CurrencyUi +import com.ivy.data.CurrencyCode +import com.ivy.data.IvyCurrency +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +@HiltViewModel +internal class CurrencyPickerModalViewModel @Inject constructor( + private val accountsFlow: AccountsFlow, +) : SimpleFlowViewModel() { + override val initialUi = CurrencyModalState( + items = emptyList(), + suggested = emptyList(), + selectedCurrency = null, + searchQuery = "", + ) + + private var searchQuery = MutableStateFlow("") + private val selectedCurrency = MutableStateFlow(null) + + override val uiFlow: Flow = combine( + currenciesFlow(), selectedCurrency, suggestedFlow() + ) { currencies, selectedCurrency, suggested -> + CurrencyModalState( + items = currencies, + suggested = suggested, + selectedCurrency = selectedCurrency, + searchQuery = searchQuery.value, + ) + } + + private fun currenciesFlow(): Flow> = combine( + availableCurrenciesFlow(), searchQueryFlow() + ) { allCurrencies, searchQuery -> + val currencies = if (searchQuery != null) + allCurrencies.filter { it.passesSearch(searchQuery) } + else allCurrencies + + currencies.groupBy { it.code.first() } + .toSortedMap() + .flatMap { (letter, currencies) -> + listOf( + CurrencyListItem.SectionDivider(name = letter.uppercase()), + ) + currencies.map { + CurrencyListItem.Currency( + CurrencyUi( + code = it.code, + name = if (it.name.isNotEmpty()) + // capitalize the first letter + "${it.name.first().uppercase()}${it.name.drop(1)}" else "" + ) + ) + } + } + } + + private fun IvyCurrency.passesSearch(searchQuery: String): Boolean = + code.lowercase().contains(searchQuery) || name.lowercase().contains(searchQuery) + + @OptIn(FlowPreview::class) + private fun searchQueryFlow(): Flow = searchQuery.map { + it.lowercase().trim().takeIf(String::isNotEmpty) // normalize search query + }.debounce(100) + + private fun availableCurrenciesFlow(): Flow> = + flowOf(IvyCurrency.getAvailable()) + + private fun suggestedFlow(): Flow> = accountsFlow().map { accounts -> + accounts.map { it.currency }.toSet() + }.map { accountCurrencies -> + accountCurrencies.plus( + listOf( + "USD", + "EUR", + "INR", + "GBP" + ) + ).toList().sorted() + } + + // region Event Handling + override suspend fun handleEvent(event: CurrencyModalEvent) = when (event) { + is CurrencyModalEvent.Search -> handleSearch(event) + is CurrencyModalEvent.SelectCurrency -> handleSelectCurrency(event) + is CurrencyModalEvent.SelectCurrencyCode -> handleSelectCurrencyCode(event) + is CurrencyModalEvent.Initial -> handleInitial(event) + } + + private fun handleSearch(event: CurrencyModalEvent.Search) { + searchQuery.value = event.query + } + + private fun handleSelectCurrency(event: CurrencyModalEvent.SelectCurrency) { + selectedCurrency.value = event.currencyUi + } + + private fun handleSelectCurrencyCode(event: CurrencyModalEvent.SelectCurrencyCode) { + findCurrency(event.currencyCode)?.let { + selectedCurrency.value = it + } + } + + private fun handleInitial(event: CurrencyModalEvent.Initial) { + findCurrency(event.initialCurrency)?.let { + selectedCurrency.value = it + } + } + + private fun findCurrency(code: CurrencyCode): CurrencyUi? = (uiState.value.items + .firstOrNull { + (it as? CurrencyListItem.Currency)?.currency?.code == code + } as? CurrencyListItem.Currency)?.currency + // endregion +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/currency/data/CurrencyListItem.kt b/core/ui/src/main/java/com/ivy/core/ui/currency/data/CurrencyListItem.kt new file mode 100644 index 0000000..e8de793 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/currency/data/CurrencyListItem.kt @@ -0,0 +1,12 @@ +package com.ivy.core.ui.currency.data + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface CurrencyListItem { + @Immutable + data class Currency(val currency: CurrencyUi) : CurrencyListItem + + @Immutable + data class SectionDivider(val name: String) : CurrencyListItem +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/currency/data/CurrencyUi.kt b/core/ui/src/main/java/com/ivy/core/ui/currency/data/CurrencyUi.kt new file mode 100644 index 0000000..de74ded --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/currency/data/CurrencyUi.kt @@ -0,0 +1,10 @@ +package com.ivy.core.ui.currency.data + +import androidx.compose.runtime.Immutable +import com.ivy.data.CurrencyCode + +@Immutable +data class CurrencyUi( + val code: CurrencyCode, + val name: String, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/data/CategoryUi.kt b/core/ui/src/main/java/com/ivy/core/ui/data/CategoryUi.kt new file mode 100644 index 0000000..8175b22 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/data/CategoryUi.kt @@ -0,0 +1,34 @@ +package com.ivy.core.ui.data + +import androidx.annotation.ColorInt +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import com.ivy.core.ui.R +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.core.ui.data.icon.dummyIconSized +import com.ivy.design.l0_system.color.Purple +import java.util.* + +@Immutable +data class CategoryUi( + val id: String, + val name: String, + val color: Color, + val icon: ItemIcon, + val hasParent: Boolean, +) + +fun dummyCategoryUi( + name: String = "Category", + @ColorInt + color: Color = Purple, + icon: ItemIcon = dummyIconSized(R.drawable.ic_custom_category_s), + hasParent: Boolean = false, + id: String = UUID.randomUUID().toString(), +) = CategoryUi( + id = id, + name = name, + color = color, + icon = icon, + hasParent = hasParent, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/data/account/AccountUi.kt b/core/ui/src/main/java/com/ivy/core/ui/data/account/AccountUi.kt new file mode 100644 index 0000000..1e28663 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/data/account/AccountUi.kt @@ -0,0 +1,32 @@ +package com.ivy.core.ui.data.account + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import com.ivy.core.ui.R +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.core.ui.data.icon.dummyIconSized +import com.ivy.design.l0_system.color.Green +import java.util.* + +@Immutable +data class AccountUi( + val id: String, + val name: String, + val color: Color, + val icon: ItemIcon, + val excluded: Boolean, +) + +fun dummyAccountUi( + name: String = "Account", + id: String = UUID.randomUUID().toString(), + color: Color = Green, + icon: ItemIcon = dummyIconSized(R.drawable.ic_custom_account_s), + excluded: Boolean = false, +) = AccountUi( + id = id, + name = name, + color = color, + icon = icon, + excluded = excluded, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/data/account/FolderUi.kt b/core/ui/src/main/java/com/ivy/core/ui/data/account/FolderUi.kt new file mode 100644 index 0000000..39ff529 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/data/account/FolderUi.kt @@ -0,0 +1,32 @@ +package com.ivy.core.ui.data.account + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import com.ivy.core.ui.R +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.core.ui.data.icon.dummyIconUnknown +import com.ivy.design.l0_system.color.Purple +import java.util.* + +@Immutable +data class FolderUi( + val id: String, + val name: String, + val icon: ItemIcon, + val color: Color, + val orderNum: Double, +) + +fun dummyFolderUi( + name: String = "Folder", + id: String = UUID.randomUUID().toString(), + icon: ItemIcon = dummyIconUnknown(R.drawable.ic_vue_files_folder), + color: Color = Purple, + orderNum: Double = 0.0, +) = FolderUi( + id = id, + name = name, + icon = icon, + color = color, + orderNum = orderNum, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/data/icon/DummyIcon.kt b/core/ui/src/main/java/com/ivy/core/ui/data/icon/DummyIcon.kt new file mode 100644 index 0000000..b357bf5 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/data/icon/DummyIcon.kt @@ -0,0 +1,21 @@ +package com.ivy.core.ui.data.icon + +import androidx.annotation.DrawableRes + +fun dummyIconSized( + @DrawableRes + icon: Int +) = ItemIcon.Sized( + iconS = icon, + iconM = icon, + iconL = icon, + iconId = null +) + +fun dummyIconUnknown( + @DrawableRes + icon: Int +) = ItemIcon.Unknown( + icon = icon, + iconId = null +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/data/icon/IconSize.kt b/core/ui/src/main/java/com/ivy/core/ui/data/icon/IconSize.kt new file mode 100644 index 0000000..0d57b5a --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/data/icon/IconSize.kt @@ -0,0 +1,10 @@ +package com.ivy.core.ui.data.icon + +import androidx.compose.runtime.Immutable + +@Immutable +enum class IconSize(val value: String) { + S("s"), + M("m"), + L("l") +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/data/icon/ItemIcon.kt b/core/ui/src/main/java/com/ivy/core/ui/data/icon/ItemIcon.kt new file mode 100644 index 0000000..f894e0f --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/data/icon/ItemIcon.kt @@ -0,0 +1,31 @@ +package com.ivy.core.ui.data.icon + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.ivy.data.ItemIconId + +@Immutable +sealed interface ItemIcon { + @Immutable + data class Sized( + @DrawableRes + val iconS: Int, + @DrawableRes + val iconM: Int, + @DrawableRes + val iconL: Int, + val iconId: ItemIconId?, + ) : ItemIcon + + @Immutable + data class Unknown( + @DrawableRes + val icon: Int, + val iconId: ItemIconId?, + ) : ItemIcon +} + +fun ItemIcon.iconId(): ItemIconId? = when (this) { + is ItemIcon.Sized -> iconId + is ItemIcon.Unknown -> iconId +} diff --git a/core/ui/src/main/java/com/ivy/core/ui/data/period/MonthUi.kt b/core/ui/src/main/java/com/ivy/core/ui/data/period/MonthUi.kt new file mode 100644 index 0000000..aa4a0ff --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/data/period/MonthUi.kt @@ -0,0 +1,43 @@ +package com.ivy.core.ui.data.period + +import android.content.Context +import androidx.compose.runtime.Immutable +import com.ivy.resources.R + +@Immutable +data class MonthUi( + val number: Int, + val year: Int, + val currentYear: Boolean, + val fullName: String, +) + +fun monthsList( + context: Context, year: Int, currentYear: Boolean +): List = + (1..12).map { number -> + MonthUi( + number = number, + year = year, + currentYear = currentYear, + fullName = fullMonthName(context = context, monthNumber = number) + ) + } + +fun fullMonthName(context: Context, monthNumber: Int): String = when (monthNumber) { + 1 -> context.getString(R.string.january) + 2 -> context.getString(R.string.february) + 3 -> context.getString(R.string.march) + 4 -> context.getString(R.string.april) + 5 -> context.getString(R.string.may) + 6 -> context.getString(R.string.june) + 7 -> context.getString(R.string.july) + 8 -> context.getString(R.string.august) + 9 -> context.getString(R.string.september) + 10 -> context.getString(R.string.october) + 11 -> context.getString(R.string.november) + 12 -> context.getString(R.string.december) + else -> error("Invalid month with num $monthNumber. Must be in [1,12].") +} + +fun dummyMonthUi() = MonthUi(number = 1, year = 1, currentYear = true, fullName = "") \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/data/period/SelectedPeriodUi.kt b/core/ui/src/main/java/com/ivy/core/ui/data/period/SelectedPeriodUi.kt new file mode 100644 index 0000000..b92f7a7 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/data/period/SelectedPeriodUi.kt @@ -0,0 +1,37 @@ +package com.ivy.core.ui.data.period + +import androidx.compose.runtime.Immutable +import com.ivy.data.time.TimeUnit + +@Immutable +sealed interface SelectedPeriodUi { + val periodBtnText: String + val rangeUi: TimeRangeUi + + @Immutable + data class Monthly( + override val periodBtnText: String, + val month: MonthUi, + override val rangeUi: TimeRangeUi, + ) : SelectedPeriodUi + + @Immutable + data class InTheLast( + override val periodBtnText: String, + val n: Int, + val unit: TimeUnit, + override val rangeUi: TimeRangeUi, + ) : SelectedPeriodUi + + @Immutable + data class AllTime( + override val periodBtnText: String, + override val rangeUi: TimeRangeUi, + ) : SelectedPeriodUi + + @Immutable + data class CustomRange( + override val periodBtnText: String, + override val rangeUi: TimeRangeUi, + ) : SelectedPeriodUi +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/data/period/TimeRangeUi.kt b/core/ui/src/main/java/com/ivy/core/ui/data/period/TimeRangeUi.kt new file mode 100644 index 0000000..913e72a --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/data/period/TimeRangeUi.kt @@ -0,0 +1,18 @@ +package com.ivy.core.ui.data.period + +import androidx.compose.runtime.Immutable +import com.ivy.common.time.timeNow +import com.ivy.data.time.TimeRange + +@Immutable +data class TimeRangeUi( + val range: TimeRange, + val fromText: String, + val toText: String, +) + +fun dummyRangeUi() = TimeRangeUi( + range = TimeRange(timeNow(), timeNow()), + fromText = "", + toText = "" +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/data/transaction/TrnTimeUi.kt b/core/ui/src/main/java/com/ivy/core/ui/data/transaction/TrnTimeUi.kt new file mode 100644 index 0000000..17109d0 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/data/transaction/TrnTimeUi.kt @@ -0,0 +1,50 @@ +package com.ivy.core.ui.data.transaction + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.platform.LocalContext +import com.ivy.common.time.deviceTimeProvider +import com.ivy.common.time.format +import com.ivy.common.time.timeNow +import com.ivy.core.ui.time.formatNicely +import java.time.LocalDateTime + +@Immutable +sealed interface TrnTimeUi { + @Immutable + data class Actual( + val actualDate: String, + val actualTime: String, + ) : TrnTimeUi + + @Immutable + data class Due( + val dueOnDate: String, + val dueOnTime: String, + val upcoming: Boolean, + ) : TrnTimeUi +} + +@Composable +fun dummyTrnTimeActualUi( + time: LocalDateTime = timeNow() +) = TrnTimeUi.Actual( + actualDate = time.formatNicely( + LocalContext.current, + deviceTimeProvider(), + ).uppercase(), + actualTime = time.format("HH:mm"), +) + +@Composable +fun dummyTrnTimeDueUi( + time: LocalDateTime = timeNow().plusHours(1), + upcoming: Boolean = true, +) = TrnTimeUi.Due( + dueOnDate = time.formatNicely( + LocalContext.current, + deviceTimeProvider() + ).uppercase(), + dueOnTime = time.format("HH:mm"), + upcoming = upcoming +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/icon/ItemIcon.kt b/core/ui/src/main/java/com/ivy/core/ui/icon/ItemIcon.kt new file mode 100644 index 0000000..5823b47 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/icon/ItemIcon.kt @@ -0,0 +1,192 @@ +package com.ivy.core.ui.icon + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.rememberAsyncImagePainter +import com.ivy.core.ui.R +import com.ivy.core.ui.data.icon.IconSize +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.design.l0_system.UI +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.isInPreview + +@Composable +fun ItemIcon( + itemIcon: ItemIcon, + size: IconSize, + modifier: Modifier = Modifier, + tint: Color = UI.colorsInverted.pure +) { + val sizeModifier = remember(modifier, size) { + modifier.size(size.toDp()) + } + when (itemIcon) { + is ItemIcon.Sized -> AsyncIcon( + modifier = sizeModifier, + icon = itemIcon.icon(size), + tint = tint, + ) + is ItemIcon.Unknown -> Image( + modifier = sizeModifier + .padding(all = 4.dp), + painter = previewSafeAsyncPainter( + icon = itemIcon.icon, + contentScale = ContentScale.FillBounds + ), + contentDescription = "itemIcon", + colorFilter = ColorFilter.tint(tint), + alignment = Alignment.Center, + contentScale = ContentScale.FillBounds, + ) + } +} + +@DrawableRes +fun ItemIcon.Sized.icon(size: IconSize): Int = when (size) { + IconSize.S -> iconS + IconSize.M -> iconM + IconSize.L -> iconL +} + +fun IconSize.toDp(): Dp = when (this) { + IconSize.S -> 32.dp + IconSize.M -> 48.dp + IconSize.L -> 64.dp +} + +// region AsyncIcon +@Composable +private fun AsyncIcon( + @DrawableRes + icon: Int, + tint: Color, + modifier: Modifier = Modifier, +) { + Icon( + modifier = modifier, + painter = previewSafeAsyncPainter(icon), + tint = tint, + contentDescription = null, + ) +} + +@Composable +private fun previewSafeAsyncPainter( + @DrawableRes + icon: Int, + contentScale: ContentScale = ContentScale.Fit, +) = if (isInPreview()) painterResource(icon) else rememberAsyncImagePainter( + model = icon, + contentScale = contentScale, + filterQuality = FilterQuality.None, +) +// endregion + + +//region Previews +@Preview +@Composable +private fun Preview_Sized_S() { + ComponentPreview { + ItemIcon( + itemIcon = ItemIcon.Sized( + iconS = R.drawable.ic_custom_account_s, + iconM = 0, + iconL = 0, + iconId = null + ), + size = IconSize.S + ) + } +} + +@Preview +@Composable +private fun Preview_Sized_M() { + ComponentPreview { + ItemIcon( + ItemIcon.Sized( + iconS = 0, + iconM = R.drawable.ic_custom_account_m, + iconL = 0, + iconId = null + ), + size = IconSize.M + ) + } +} + +@Preview +@Composable +private fun Preview_Sized_L() { + ComponentPreview { + ItemIcon( + itemIcon = ItemIcon.Sized( + iconS = 0, + iconM = 0, + iconL = R.drawable.ic_custom_account_l, + iconId = null + ), + size = IconSize.L + ) + } +} + +@Preview +@Composable +private fun Preview_Unknown_S() { + ComponentPreview { + ItemIcon( + ItemIcon.Unknown( + icon = R.drawable.ic_vue_crypto_cardano, + iconId = null + ), + size = IconSize.S + ) + } +} + +@Preview +@Composable +private fun Preview_Unknown_M() { + ComponentPreview { + ItemIcon( + itemIcon = ItemIcon.Unknown( + icon = R.drawable.ic_vue_crypto_cardano, + iconId = null + ), + size = IconSize.M + ) + } +} + +@Preview +@Composable +private fun Preview_Unknown_L() { + ComponentPreview { + ItemIcon( + itemIcon = ItemIcon.Unknown( + icon = R.drawable.ic_vue_crypto_cardano, + iconId = null + ), + size = IconSize.L + ) + } +} +//endregion + diff --git a/core/ui/src/main/java/com/ivy/core/ui/icon/picker/IconPickerEvent.kt b/core/ui/src/main/java/com/ivy/core/ui/icon/picker/IconPickerEvent.kt new file mode 100644 index 0000000..3807904 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/icon/picker/IconPickerEvent.kt @@ -0,0 +1,5 @@ +package com.ivy.core.ui.icon.picker + +internal sealed interface IconPickerEvent { + data class Search(val query: String) : IconPickerEvent +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/icon/picker/IconPickerIcons.kt b/core/ui/src/main/java/com/ivy/core/ui/icon/picker/IconPickerIcons.kt new file mode 100644 index 0000000..cebae2f --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/icon/picker/IconPickerIcons.kt @@ -0,0 +1,1981 @@ +package com.ivy.core.ui.icon.picker + +import com.ivy.core.ui.icon.picker.data.Icon +import com.ivy.core.ui.icon.picker.data.SectionUnverified + +// region Icons +internal fun pickerItems(): List = listOf( + SectionUnverified(name = "Ivy", icons = ivyIcons()), + SectionUnverified(name = "Brands", icons = vueBrands()), + SectionUnverified(name = "Building", icons = vueBuilding()), + SectionUnverified(name = "Chart", icons = vueChart()), + SectionUnverified(name = "Crypto", icons = vueCrypto()), + SectionUnverified(name = "Delivery", icons = vueDelivery()), + SectionUnverified(name = "Design", icons = vueDesign()), + SectionUnverified(name = "Dev", icons = vueDev()), + SectionUnverified(name = "Education", icons = vueEducation()), + SectionUnverified(name = "Files", icons = vueFiles()), + SectionUnverified(name = "Location", icons = vueLocation()), + SectionUnverified(name = "Main", icons = vueMain()), + SectionUnverified(name = "Media", icons = vueMedia()), + SectionUnverified(name = "Messages", icons = vueMessages()), + SectionUnverified(name = "Money", icons = vueMoney()), + SectionUnverified(name = "PC", icons = vuePC()), + SectionUnverified(name = "People", icons = vuePeople()), + SectionUnverified(name = "Security", icons = vueSecurity()), + SectionUnverified(name = "Shop", icons = vueShop()), + SectionUnverified(name = "Support", icons = vueSupport()), + SectionUnverified(name = "Transport", icons = vueTransport()), + SectionUnverified(name = "Type", icons = vueType()), + SectionUnverified(name = "Weather", icons = vueWeather()) +) +// endregion + +// region Ivy Icons +private fun ivyIcons(): List = listOf( + Icon( + "account", keywords = listOf( + "accounts", "wallets", "pocket", "notecases", "money", + "savings", "finances", "cash", "assets", "amounts", "balance" + ) + ), + Icon("category", keywords = listOf("category", "jars", "jams", "groups", "sections")), + Icon( + "cash", keywords = listOf( + "cash", "money", "dollars", "usd", "cents", "coins", + "balance" + ) + ), + Icon( + "bank", keywords = listOf( + "banks", "cards", "money", "accounts", "savings", + "finances", "debit", "credit", "assets", "amounts", "balance" + ) + ), + Icon( + "revolut", keywords = listOf( + "revolut", "accounts", "banks", "cards", "money", "savings", + "finances", "assets", "amounts", "balance" + ) + ), + Icon( + "clothes2", keywords = listOf( + "clothes", "appearance", "outfits", "blouses", + "t-shirts", "apparels", "wardrobe", "shopping", "stores", "closets" + ) + ), + Icon( + "clothes", keywords = listOf( + "clothes", "wardrobe", "hangers", "appearance", "outfits", "blouses", + "t-shirts", "apparels", "shopping", "stores", "closets", "storages" + ) + ), + Icon( + "family", keywords = listOf( + "family", "homes", "couple", "kids", "children", + "love", "partners", "wifes", "husbands", "boyfriends", "girlfriends", "fiancee", + "hearts", "relatives", "people" + ) + ), + Icon( + "star", keywords = listOf( + "stars", "favorites", "favourites", "tops", "reviews", + "success", "achievements", "christmas", "xmas", "premium" + ) + ), + Icon( + "education", keywords = listOf( + "education", "school", "university", "study", "learning", "hats", "academy", + "high school" + ) + ), + Icon( + "fitness", keywords = listOf( + "fitness", "gym", "workouts", "train", "weights", "sports", "lifting", "dumbbells", + "work out" + ) + ), + Icon( + "loan", keywords = listOf( + "loans", "bills", "notes", "receipts", "recipes", + "reports", "invoices", "fees", "taxes", "expenses" + ) + ), + Icon( + "orderfood", keywords = listOf( + "orders", "delivery", "boxes", "chinese", "foods", + "dine", "dining", "lunches", "delivery", "eating" + ) + ), + Icon( + "orderfood2", keywords = listOf( + "orders", "delivery", "scooters", "takeaways", + "glovo", "foodpanda" + ) + ), + Icon("pet", keywords = listOf("pets", "dogs", "paws", "cats")), + Icon( + "restaurant", keywords = listOf( + "restaurants", "eating", "dinners", "foods", "dine", "dining", + "lunch", "cutlery", "forks", "knifes", "meals", "diets", "nutritions" + ) + ), + Icon( + "selfdevelopment", keywords = listOf( + "learn", "improvements", "level up", "grow", "self development", "developing", + "success", "achievements", "arrows", "person", "faith", "god", "learning", "top", "high" + ) + ), + Icon( + "work", + keywords = listOf( + "works", "cases", "jobs", "occupations", "businesses", "professions", "hustles", + "labours", "labors", "careers", "assignments", "company", "organizations" + ) + ), + Icon( + "vehicle", + keywords = listOf( + "cars", "vehicles", "autos", "transports", "commutes", "gas", "taxis" + ) + ), + Icon( + "atom", keywords = listOf( + "atoms", "sciences", "labs", "universes", "physics", "fantastic" + ) + ), + Icon( + "bills", keywords = listOf( + "bills", "accounts", "taxes", "fees", "books", + "reads", "notes", "diary", "organise", "plans", "library", + "organize" + ) + ), + Icon( + "birthday", keywords = listOf( + "birthdays", "cakes", "candles", "surprises", "bdays", + "b-day" + ) + ), + Icon( + "calculator", keywords = listOf( + "calculates", "calculators", "calculations", "maths", + "numbers", "finances" + ) + ), + Icon( + "camera", keywords = listOf( + "cameras", "videos", "editing", "photos", "movies", "records", "directing", "studios", + "shows", "tvs", "streams", "acts", "actions", "produces", "productions", "acting", + "films", "vlogs", "vlogging" + ) + ), + Icon( + "chemistry", keywords = listOf( + "chemistry", "cones", "sciences", "potions", "elixirs", + "pharmacy", "labs" + ) + ), + Icon( + "coffee", keywords = listOf( + "coffees", "cafes", "hot", "mornings", "wake up", "warm", + "energy", "drinks", "fun", "cups", "mugs", "glasses" + ) + ), + Icon( + "connect", keywords = listOf( + "connections", "structures", "technology", "nets", "webs", "trees", + "groups", "logistics" + ) + ), + Icon( + "dna", keywords = listOf( + "dnas", "lifes", "health", "genes", "sciences", "cells", + "labs" + ) + ), + Icon( + "doctor", keywords = listOf( + "doctors", "checks", "medics", "sick", "ill", "gp", + "examinations", "hospitals", "clinics", "prescripts", "recipes" + ) + ), + Icon( + "document", keywords = listOf( + "documents", "papers", "lists", "notes", "texts", "agenda", + "messages", "news", "magazines", "diary", "plans", "tasks", "organise", "organize", + "bills", "taxes", "fees", "accounts", "reports", "receipts", "recipes", "prescripts", + "labels", "orders", "warranty", "insurances", "policy", "scripts", "content", "write", + "copy", "writing", "create", "assignments", "to-do", "todos", "contracts", "library", + "tests", "exams", "portfolios", "cvs", "articles" + ) + ), + Icon( + "drink", keywords = listOf( + "drinks", "celebrates", "celebrating", "party", "beers", "leisure", "spare", + "free", "glasses", "cups", "cheers", "bars", "clubs", "holidays", "out", "toasts", + "fun", "alcohols", "rest" + ) + ), + Icon( + "farmacy", keywords = listOf( + "farmacy", "pharmacy", "pills", "medics", "treats", "cures", + "prescripts", "healing", "recipes", "hospitals", "clinics", "sick", "ill" + ) + ), + Icon( + "fingerprint", keywords = listOf( + "prints", "fingers", "authenticates", "secure", "policy", + "sensors", "traces", "examines", "unlocks", "identify", "touches", "safe" + ) + ), + Icon( + "fishfood", keywords = listOf( + "fishes", "foods", "sea", "oceans", "lakes", "dams", "rivers", + "hobby", "lunches", "dine", "dining", "dinner", "delivery", "rest", "fishfoods" + ) + ), + Icon( + "food2", keywords = listOf( + "foods", "delivery", "orders", "pizzas", "dine", "dining", "party", + "lunches", "brunches", "netflix", "fun", "rest" + ) + ), + Icon( + "fooddrink", keywords = listOf( + "foods", "delivers", "orders", "pizzas", "dine", "dining", + "wines", "glasses", "cups", "drinks", "cheers", "fun", "toasts", "bars", "clubs", + "alcohols", "holidays", "celebrates", "leisure", "spare", "free", "out", "rest", + "celebrations" + ) + ), + Icon( + "furniture", keywords = listOf( + "furniture", "houses", "cabinets", "homes", + "cupboards", "wardrobes", "dressing", "rooms", "drawers", "stores", "storages", + "organize", "organise", "closets" + ) + ), + Icon( + "gambling", keywords = listOf( + "gambling", "plays", "casinos", "games", "bets", "dices", + "risks", "poker" + ) + ), + Icon( + "game", keywords = listOf( + "games", "gaming", "plays", "consoles", "ps", "pc", "nintendos", "xboxes", + "hobby", "spare", "free", "leisure", "chill", "computers", "computers" + ) + ), + Icon( + "gears", keywords = listOf( + "gears", "maintenance", "maintaining", "cars", "mechanisms", "repairs", "technology", + "settings", "tunes", "adjusts" + ) + ), + Icon( + "gift", keywords = listOf( + "gifts", "party", "celebrate", "celebrations", "presents", "donations", "donates", + "birthdays", "bdays", "b-day", "holidays" + ) + ), + Icon( + "groceries", keywords = listOf( + "groceries", "grocery", "markets", "supplies", "shops", "trade", "trading", + "businesses", "franchises", "buy", "stores", "orders", "sells", "sales" + ) + ), + Icon( + "hairdresser", keywords = listOf( + "hairdressers", "parlor", "parlour", "salon", "saloon", "beauty", "beautify", + "hairstyles", "haircuts", "dry", "shave", "beards", "dye", "hairdressings" + ) + ), + Icon( + "health", keywords = listOf( + "health", "medics", "doctors", "hospitals", "pills", "cases", + "pharmacy", "treats", "cures", "prescripts", "healing", "recipes", "clinics", "sick", + "ill", "farmacy" + ) + ), + Icon( + "hike", keywords = listOf( + "hike", "hikings", "mountains", "walks", "tops", "nature", "hobby", + "forests", "woods", "trees", "environments", "sports" + ) + ), + Icon( + "house", keywords = listOf( + "houses", "mortgages", "homes", "apartments", "buildings", + "property", "chores", "estates", "accommodations", "rents", "sales", "airbnb", "lives", + "places", "hosts", "living", "remotely" + ) + ), + Icon( + "insurance", keywords = listOf( + "insurances", "bills", "protections", "fees", "security", "secure", + "policy", "safety" + ) + ), + Icon( + "label", keywords = listOf( + "labels", "tags", "prices", "costs", "value", "rates", + "charges", "worth", "markets", "shops", "stores", "buy", "tickets" + ) + ), + Icon( + "leaf", keywords = listOf( + "leaf", "leaves", "plants", "lawns", "gardens", "eco", "bio", + "grasses", "green", "vegetarians", "vegans", "nature", "naturals", "flowers", "trees", + "spring", "autumn", "fall", "productions", "produce", "seeds", "environments" + ) + ), + Icon( + "location", keywords = listOf( + "locations", "gps", "places", "maps", "address", + "live", "delivery", "geography" + ) + ), + Icon( + "makeup", keywords = listOf( + "makeups", "make up", "parlor", "parlour", "beauty", "beautify", "salon", "saloon", + "lipstick", "lip balm", "highlights", "paints", "cuts", "knifes", "diy", "glues", + "sharps", "lip gloss", "lipgloss", "makeup artists" + ) + ), + Icon( + "music", keywords = listOf( + "music", "headsets", "headphones", "sounds", "spotify", "singers", "songs", "hear", + "fun", "party", "records", "directing", "radios", "produce", "production", "hits", + "tunes", "performing", "recordings" + ) + ), + Icon( + "notice", keywords = listOf( + "notice", "warnings", "urgents", "attention", "requirements", + "musts", "important", "priority", "crucial", "dangerous" + ) + ), + Icon( + "people", keywords = listOf( + "peoples", "gathering", "contacts", "calls", "person", + "friends", "relatives", "family", "communicate", "communications", "speakings", + "talkings", "groups" + ) + ), + Icon( + "plant", keywords = listOf( + "plants", "trees", "gardens", "yards", "lawns", "cactus", "cacti", "leaf", "leaves", + "eco", "bio", "grasses", "green", "vegetarians", "vegan", "nature", "naturals", + "flowers", "trees", "produce", "productions", "seeds", "environments", "sharps" + ) + ), + Icon( + "programming", keywords = listOf( + "programming", "programmer", "coder", "coding", "softwares", "logician", + "engineers", "engineering", "it", "technology", "library", "developers", "programs", + "development", "developing" + ) + ), + Icon( + "relationship", keywords = listOf( + "relationships", "love", "hearts", "partners", + "couples", "health", "wifes", "husbands", "family", "insurances", "boyfriend", + "girlfriends", "fiancee", "homes" + ) + ), + Icon( + "rocket", keywords = listOf( + "rockets", "moon", "spaces", "yolo", "fly", "universes", + "achievements", "determination", "brilliant", "success", "businesses", + "ideas", "tops", "rise", "high" + ) + ), + Icon( + "safe", keywords = listOf( + "safety", "secure", "security", "protections", "locks", "guards", + "insurances", "measures" + ) + ), + Icon( + "sail", + keywords = listOf( + "sailing", "sails", "anchors", "cruises", "boats", "yachts", "travels", "travelling", + "vacations", "ships", "sea", "adventures", "oceans", "rivers", "fishes", "dive", + "pisces", "diving" + ) + ), + Icon( + "server", keywords = listOf( + "servers", "modems", "communicate", "communications", "pc", "computers", "messages", + "texts", "chats", "bubbles", "backends", "developers", "programs", "machines", + "softwares", "engineers", "coder", "logicians", "it", "technology", "library", "hosts", + "hosting", "engineering", "coding", "programming", "developing", "development", + "chatting" + ) + ), + Icon( + "shopping2", keywords = listOf( + "shops", "shopping", "carts", "grocery", "groceries", "markets", "stores", "orders", + "buy", "baby", "children", "motherhood", "fatherhood", "parenthood", "kids" + ) + ), + Icon( + "shopping", keywords = listOf( + "shopping", "shops", "baskets", "totes", "bags", "boxes", + "drawers", "packets", "delivery", "buy", "stores", "orders" + ) + ), + Icon( + "sports", keywords = listOf( + "sports", "balls", "soccers", "hobby", "plays", + "games" + ) + ), + Icon( + "stats", keywords = listOf( + "stats", "chart", "hearts", "stocks", "health", "hospitals", "doctors", + "checks", "medics", "sick", "ill", "gp", "examinations", "clinics", "prescripts", + "recipes", "measurements", "tests", "measures", "cardiograms" + ) + ), + Icon( + "tools", keywords = listOf( + "tools", "equipments", "hammers", "buildings", "producing", "produce", + "wood", "progressings", "builders", "repairments", "repairs", "wip" + ) + ), + Icon( + "transport", keywords = listOf( + "transports", "cars", "buses", "commutes", + "vehicles", "autos", "transports", "gas", "taxis", "buses", "trams", "subways", + "trolleys", "trains", "metros", "underground", "gas" + ) + ), + Icon( + "travel", keywords = listOf( + "traveling", "travels", "trips", "airplanes", "abroad", + "explore", "vacations", "exploring", "experiences", "flying", "flights" + ) + ), + Icon( + "trees", keywords = listOf( + "trees", "gardens", "yards", "lawns", "woods", + "christmas", "xmas", "forests" + ) + ), + Icon( + "zeus", keywords = listOf( + "zeus", "lightning", "rains", "emergency", "urgents", "sparks", + "storms", "flash", "thunders", "important", "priority", "thor" + ) + ), + Icon( + "calendar", keywords = listOf( + "calendars", "plans", "schedules", "memos", + "planners", "notes", "tasks", "priority", "agenda" + ) + ), + Icon( + "crown", keywords = listOf( + "crowns", "luxury", "vip", "top", "queens", "kings", + "the best", "luxe" + ) + ), + Icon( + "diamond", keywords = listOf( + "diamonds", "luxury", "luxe", "vip", "weddings", + "rings", "tops", "expensive", "glamorous", "shine", "shining", "sparkling", "brilliant", + "glory", "sparkle", "premium" + ) + ), + Icon( + "palette", keywords = listOf( + "palettes", "painters", "artists", "colors", "colours", + "decorate", "decorations", "dyeing", "painting", "drawing", "pictures", "crayons", + "brushes" + ) + ), +) +// endregion + +// region Brands (Vue) +private fun vueBrands(): List = listOf( + Icon("ic_vue_brands_triangle", keywords = listOf("triangles")), + Icon( + "ic_vue_brands_trello", keywords = listOf( + "trello", "managements", "projects", "pm", "tasks" + ) + ), + Icon( + "ic_vue_brands_html5", keywords = listOf( + "html5", "html", "coder", "startup", "projects", "softwares", + "development", "programming", "programmer", "programs", "developers", "websites" + ) + ), + Icon( + "ic_vue_brands_spotify", keywords = listOf( + "spotify", "music", "songs", "listening", "streaming", "streams" + ) + ), + Icon( + "ic_vue_brands_bootsrap", keywords = listOf( + "bootsrap", "startup", "coder", + "coding", "open-source", "open source", "softwares", "projects", + "development", "programming", "programmer", "programs", "developers", "websites" + ) + ), + Icon( + "ic_vue_brands_dribbble", + keywords = listOf( + "dribbble", "dribble", "designers", "designs" + ) + ), + Icon( + "ic_vue_brands_google_play", keywords = listOf( + "google play", "apps", "android", "applications", "downloading" + ) + ), + Icon( + "ic_vue_brands_dropbox", keywords = listOf( + "dropbox", "clouds", "data", "save", "sync" + ) + ), + Icon( + "ic_vue_brands_js", keywords = listOf( + "js", "javascript", "coder", "coding", "websites", + "softwares", "projects", "development", "programming", "programmer", "programs", + "developers" + ) + ), + Icon( + "ic_vue_brands_drive", keywords = listOf( + "google drive", "save", "files", "clouds" + ) + ), + Icon("ic_vue_brands_paypal", keywords = listOf("paypal", "transfer money online")), + Icon("ic_vue_brands_be", keywords = listOf("be")), + Icon("ic_vue_brands_figma", keywords = listOf("figma", "designers", "designs")), + Icon( + "ic_vue_brands_messenger", + keywords = listOf( + "messenger", "messages", "chatting", "chats", "talking", "communication", "communicate", + "sending", "texting" + ) + ), + Icon("ic_vue_brands_facebook", keywords = listOf("facebook", "fb", "social media")), + Icon("ic_vue_brands_framer", keywords = listOf("framer", "web builder", "websites")), + Icon( + "ic_vue_brands_whatsapp", keywords = listOf( + "whatsapp", "communication", "communicate", "messages", "chatting", "chats", "talking", + "sending", "texting" + ) + ), + Icon( + "ic_vue_brands_html3", keywords = listOf( + "html3", "html", "web programming", + "coder", "coding", "websites", "softwares", "projects", "development", "programming", + "programmer", "programs", "developers" + ) + ), + Icon( + "ic_vue_brands_zoom", keywords = listOf( + "zoom", "communication", "communicate", "meetings" + ) + ), + Icon("ic_vue_brands_ok", keywords = listOf("ok")), + Icon( + "ic_vue_brands_twitch", keywords = listOf( + "twitch", "streaming", "gaming", "entertainment", "sports", "fun" + ) + ), + Icon( + "ic_vue_brands_youtube", keywords = listOf( + "youtube", "music", "learning", "videos", + "streaming", "vlogs", "vlogging", "hits", "fun", "chill", "subscriptions" + ) + ), + Icon( + "ic_vue_brands_apple", keywords = listOf( + "apple", "iphone", "ipad", "macbook", + "iwatch", "laptops", "technology", "ios" + ) + ), + Icon( + "ic_vue_brands_android", keywords = listOf( + "android", "mobile", "apps", "applications", "coder", "coding", "softwares", + "projects", "development", "programming", "programmer", "programs", "developers" + ) + ), + Icon( + "ic_vue_brands_slack", keywords = listOf( + "slack", "working", "chatting", "code", + "developers", "communication", "communicate" + ) + ), + Icon("ic_vue_brands_vuesax", keywords = listOf("vuesax", "webs", "code", "developers")), + Icon( + "ic_vue_brands_blogger", keywords = listOf( + "bloggers", "blogging", "hosting", + "softwares", "publishing", "writing", "content", "writers" + ) + ), + Icon( + "ic_vue_brands_photoshop", keywords = listOf( + "photoshop", "ps", "designers", "photos", "technology", "software", "editing", "pics", + "pictures", "images", "photography" + ) + ), + Icon( + "ic_vue_brands_python", keywords = listOf( + "python", "ai", "coder", "coding", "softwares", "projects", + "development", "programming", "programmer", "programs", "developers" + ) + ), + Icon( + "ic_vue_brands_google", keywords = listOf( + "google", "browsers", "browse", "searching", "internet", "software" + ) + ), + Icon( + "ic_vue_brands_xd", keywords = listOf( + "xd", "adobe", "designers", "creative", "create", "software" + ) + ), + Icon( + "ic_vue_brands_illustrator", keywords = listOf( + "illustrator", "adobe", "illustrating", + "illustrations", "designers", "creative", "create", "software" + ) + ), + Icon( + "ic_vue_brands_xiaomi", keywords = listOf( + "xiaomi", "phones", "technology", "android" + ) + ), + Icon("ic_vue_brands_windows", keywords = listOf("windows", "operational system", "os")), + Icon( + "ic_vue_brands_snapchat", keywords = listOf( + "snapchats", "fun", "snaps", "social media" + ) + ), + Icon( + "ic_vue_brands_ui8", keywords = listOf( + "ui8", "ui", "ux", "designers", "creative", "create", "creators" + ) + ), +) +// endregion + +// region Building (Vue) +private fun vueBuilding(): List = listOf( + Icon( + "ic_vue_building_building1", keywords = listOf( + "buildings", "flats", "blocks", "homes", "offices", "company" + ) + ), + Icon( + "ic_vue_building_buildings", keywords = listOf( + "buildings", "flats", "blocks", "homes", "offices", "company" + ) + ), + Icon( + "ic_vue_building_hospital", keywords = listOf( + "hospital", "buildings", "health", "church", "hospis" + ) + ), + Icon("ic_vue_building_building", keywords = listOf("buildings", "shops", "stores")), + Icon( + "ic_vue_building_bank", keywords = listOf( + "banks", "banking", "money", "finances", "fed", "institutions" + ) + ), + Icon( + "ic_vue_building_house", keywords = listOf( + "buildings", "houses", "homes", "couples", "love", "live", "remotely" + ) + ), + Icon( + "ic_vue_building_courthouse", keywords = listOf( + "courthouses", "lawyers", "legal", "businesses", "institutions", "judges" + ) + ), +) +// endregion + +// region Chart (Vue) +private fun vueChart(): List = listOf( + Icon( + "ic_vue_chart_diagram", keywords = listOf( + "diagrams", "businesses", "stocks", "investments", "crypto", "portfolios", "graphs", + "charts" + ) + ), + Icon( + "ic_vue_chart_graph", keywords = listOf( + "diagrams", "businesses", "stocks", "investments", "crypto", "portfolios", "graphs", + "charts" + ) + ), + Icon( + "ic_vue_chart_status_up", keywords = listOf( + "diagrams", "businesses", "stocks", "investments", "crypto", "portfolios", "graphs", + "charts" + ) + ), + Icon( + "ic_vue_chart_chart", keywords = listOf( + "diagrams", "businesses", "stocks", "investments", "crypto", "portfolios", "graphs", + "charts" + ) + ), + Icon( + "ic_vue_chart_trend_up", keywords = listOf( + "diagrams", "businesses", "stocks", "investments", "crypto", "portfolios", "graphs", + "charts" + ) + ), +) +// endregion + +// region Crypto (Vue) +private fun vueCrypto(): List = listOf( + Icon("ic_vue_crypto_dent", keywords = listOf("crypto", "blockchain", "currency", "dent")), + Icon("ic_vue_crypto_icon", keywords = listOf("crypto", "blockchain", "currency")), + Icon( + "ic_vue_crypto_decred", keywords = listOf( + "crypto", "blockchain", "currency", "decred" + ) + ), + Icon( + "ic_vue_crypto_ocean_protocol", keywords = listOf( + "crypto", "blockchain", "currency", "ocean protocol" + ) + ), + Icon( + "ic_vue_crypto_hedera_hashgraph", keywords = listOf( + "crypto", "blockchain", "currency", "hedera hashgraph" + ) + ), + Icon( + "ic_vue_crypto_binance_usd", keywords = listOf( + "crypto", "blockchain", "currency", + "binance", "busd" + ) + ), + Icon( + "ic_vue_crypto_maker", keywords = listOf( + "crypto", "blockchain", "currency", "maker", "mkr" + ) + ), + Icon( + "ic_vue_crypto_xrp", keywords = listOf( + "crypto", "blockchain", "currency", "xrp", "ripple" + ) + ), + Icon( + "ic_vue_crypto_harmony", keywords = listOf( + "crypto", "blockchain", "currency", "harmony", "one" + ) + ), + Icon( + "ic_vue_crypto_theta", keywords = listOf( + "crypto", "blockchain", "currency", "theta" + ) + ), + Icon( + "ic_vue_crypto_celsius_", keywords = listOf( + "crypto", "blockchain", "currency", "celsius" + ) + ), + Icon( + "ic_vue_crypto_vibe", keywords = listOf( + "crypto", "blockchain", "currency", "vibe" + ) + ), + Icon( + "ic_vue_crypto_augur", keywords = listOf( + "crypto", "blockchain", "currency", "augur" + ) + ), + Icon( + "ic_vue_crypto_graph", keywords = listOf( + "crypto", "blockchain", "currency", "hedera", "graph" + ) + ), + Icon( + "ic_vue_crypto_monero", keywords = listOf( + "crypto", "blockchain", "currency", "monero", "mnr" + ) + ), + Icon( + "ic_vue_crypto_aave", keywords = listOf( + "crypto", "blockchain", "currency", "aave" + ) + ), + Icon( + "ic_vue_crypto_dai", keywords = listOf( + "crypto", "blockchain", "currency", "dai" + ) + ), + Icon( + "ic_vue_crypto_litecoin", keywords = listOf( + "crypto", "blockchain", "currency", "litecoin" + ) + ), + Icon( + "ic_vue_crypto_tether", keywords = listOf( + "crypto", "blockchain", "currency", "tether", "ust" + ) + ), + Icon( + "ic_vue_crypto_thorchain", keywords = listOf( + "crypto", "blockchain", "currency", "thorchain" + ) + ), + Icon( + "ic_vue_crypto_nexo", keywords = listOf( + "crypto", "blockchain", "currency", "nexo" + ) + ), + Icon( + "ic_vue_crypto_chainlink", keywords = listOf( + "crypto", "blockchain", "currency", "chainlink" + ) + ), + Icon( + "ic_vue_crypto_ethereum_classic", keywords = listOf( + "crypto", "blockchain", "currency", "ethereum" + ) + ), + Icon( + "ic_vue_crypto_usd_coin", keywords = listOf( + "crypto", "blockchain", "currency", "usd" + ) + ), + Icon( + "ic_vue_crypto_nem", keywords = listOf( + "crypto", "blockchain", "currency", "nem" + ) + ), + Icon( + "ic_vue_crypto_eos", keywords = listOf( + "crypto", "blockchain", "currency", "eos" + ) + ), + Icon( + "ic_vue_crypto_emercoin", keywords = listOf( + "crypto", "blockchain", "currency", "emercoin" + ) + ), + Icon( + "ic_vue_crypto_dash", keywords = listOf( + "crypto", "blockchain", "currency", "dash" + ) + ), + Icon( + "ic_vue_crypto_ontology", keywords = listOf( + "crypto", "blockchain", "currency", "ontology" + ) + ), + Icon( + "ic_vue_crypto_ftx_token", keywords = listOf( + "crypto", "blockchain", "currency", "ftx", "tokens" + ) + ), + Icon( + "ic_vue_crypto_educare", keywords = listOf( + "crypto", "blockchain", "currency", "educare" + ) + ), + Icon( + "ic_vue_crypto_solana", keywords = listOf( + "crypto", "blockchain", "currency", "solana" + ) + ), + Icon( + "ic_vue_crypto_ethereum", keywords = listOf( + "crypto", "blockchain", "currency", "ethereum" + ) + ), + Icon( + "ic_vue_crypto_velas", keywords = listOf( + "crypto", "blockchain", "currency", "velas" + ) + ), + Icon( + "ic_vue_crypto_hex", keywords = listOf( + "crypto", "blockchain", "currency", "hex" + ) + ), + Icon( + "ic_vue_crypto_polkadot", keywords = listOf( + "crypto", "blockchain", "currency", "polkadot" + ) + ), + Icon( + "ic_vue_crypto_huobi_token", keywords = listOf( + "crypto", "blockchain", "currency", "huobi token" + ) + ), + Icon( + "ic_vue_crypto_polyswarm", keywords = listOf( + "crypto", "blockchain", "currency", "polyswarm" + ) + ), + Icon( + "ic_vue_crypto_ankr", keywords = listOf( + "crypto", "blockchain", "currency", "ankr" + ) + ), + Icon( + "ic_vue_crypto_enjin_coin", keywords = listOf( + "crypto", "blockchain", "currency", "enjin coin" + ) + ), + Icon( + "ic_vue_crypto_polygon", keywords = listOf( + "crypto", "blockchain", "currency", "polygon" + ) + ), + Icon( + "ic_vue_crypto_wing", keywords = listOf( + "crypto", "blockchain", "currency", "wing" + ) + ), + Icon( + "ic_vue_crypto_nebulas", keywords = listOf( + "crypto", "blockchain", "currency", "nebulas" + ) + ), + Icon( + "ic_vue_crypto_iost", keywords = listOf( + "crypto", "blockchain", "currency", "iost" + ) + ), + Icon( + "ic_vue_crypto_binance_coin", keywords = listOf( + "crypto", "blockchain", "currency", "binance", "coins", "bnb" + ) + ), + Icon( + "ic_vue_crypto_kyber_network", keywords = listOf( + "crypto", "blockchain", "currency", "kyber network" + ) + ), + Icon( + "ic_vue_crypto_trontron", keywords = listOf( + "crypto", "blockchain", "currency", "tron" + ) + ), + Icon( + "ic_vue_crypto_stellar", keywords = listOf( + "crypto", "blockchain", "currency", "stellar" + ) + ), + Icon( + "ic_vue_crypto_avalanche", keywords = listOf( + "crypto", "blockchain", "currency", "avalanche", "avl" + ) + ), + Icon( + "ic_vue_crypto_wanchain", keywords = listOf( + "crypto", "blockchain", "currency", "wanchain", "wan chain" + ) + ), + Icon( + "ic_vue_crypto_cardano", keywords = listOf( + "crypto", "blockchain", "currency", "ada", "cardano" + ) + ), + Icon( + "ic_vue_crypto_okb", keywords = listOf( + "crypto", "blockchain", "currency", "okb" + ) + ), + Icon( + "ic_vue_crypto_stacks", keywords = listOf( + "crypto", "blockchain", "currency", "stacks" + ) + ), + Icon( + "ic_vue_crypto_siacoin", keywords = listOf( + "crypto", "blockchain", "currency", "sia coin", "siacoin" + ) + ), + Icon( + "ic_vue_crypto_autonio", keywords = listOf( + "crypto", "blockchain", "currency", "autonio" + ) + ), + Icon( + "ic_vue_crypto_civic", keywords = listOf( + "crypto", "blockchain", "currency", "civic" + ) + ), + Icon( + "ic_vue_crypto_zel", keywords = listOf( + "crypto", "blockchain", "currency", "zel" + ) + ), + Icon( + "ic_vue_crypto_quant", keywords = listOf( + "crypto", "blockchain", "currency", "quant" + ) + ), + Icon( + "ic_vue_crypto_tenx", keywords = listOf( + "crypto", "blockchain", "currency", "tenx" + ) + ), + Icon( + "ic_vue_crypto_celo", keywords = listOf( + "crypto", "blockchain", "currency", "celo" + ) + ), + Icon( + "ic_vue_crypto_bitcoin", keywords = listOf( + "crypto", "blockchain", "currency", "btc", "bitcoins" + ) + ), +) +// endregion + +// region Delivery (Vue) +private fun vueDelivery(): List = listOf( + Icon( + "ic_vue_delivery_package", keywords = listOf( + "delivery", "delivering", "packages", "orders", "give", "take", "buy", "sell", "sales", + "packets", "boxes", "receiving", "receive", "replacement", "exchange", "swap", "gifts", + "purchases", "christmas", "xmas" + ) + ), + Icon( + "ic_vue_delivery_receive", keywords = listOf( + "delivery", "delivering", "packages", "orders", "give", "take", "buy", "sell", "sales", + "receive", "receiving", "packets", "boxes", "replacement", "exchange", "swap", "gifts", + "purchases", "christmas", "xmas" + ) + ), + Icon( + "ic_vue_delivery_box1", keywords = listOf( + "delivery", "delivering", "packages", "orders", "give", "take", "buy", "sell", "sales", + "receive", "receiving", "packets", "boxes", "gifts", "purchases", "christmas", "xmas" + ) + ), + Icon( + "ic_vue_delivery_box", keywords = listOf( + "boxes", "cubes", "delivery", "delivering", "orders", "purchases" + ) + ), + Icon( + "ic_vue_delivery_truck", keywords = listOf( + "truck", + "delivery", + "delivering", + "packages", + "orders", + "give", + "take", + "buy", + "sell", + "sales", + "packets", + "cars", + "vehicles", + "receiving", + "receive", + "replacement", + "exchange", + "swap", + "gifts", + "purchases", + "dhl", + "amazon" + ) + ), +) +// endregion + +// region Design (Vue) +private fun vueDesign(): List = listOf( + Icon( + "ic_vue_design_bezier", keywords = listOf( + "bezier", "curves", "graph", "designers", "css", "technology", "tools", "drawings", + "sketches" + ) + ), + Icon( + "ic_vue_design_brush", keywords = listOf( + "brushes", "designers", "paintings", "pictures", "art", "decorations", "decorate", + "decorating" + ) + ), + Icon( + "ic_vue_design_color_swatch", keywords = listOf( + "swatches", "designers", "fashion", "interiors", "art", "decorations", "decorate", + "decorating" + ) + ), + Icon( + "ic_vue_design_scissors", keywords = listOf( + "scissors", "designers", "cutting", "tools", "diy" + ) + ), + Icon( + "ic_vue_design_magicpen", keywords = listOf( + "magic pen", "pens", "magical", "colorful", "colourful", "fairy", "decorations", + "decorate", "decorating", "notes" + ) + ), + Icon( + "ic_vue_design_roller", keywords = listOf( + "rollers", "painters", "designers", "repairs", "repairments", "decorate", "decorating", + "decorations" + ) + ), + Icon( + "ic_vue_design_tool_pen", keywords = listOf( + "bezier", "curves", "graph", "designers", "css", "technology", "pens", "tools", + "drawings", "paintings", "sketches", "notes" + ) + ), +) +// endregion + +// region Dev (Vue) +private fun vueDev(): List = listOf( + Icon( + "ic_vue_dev_code", keywords = listOf( + "programming", "programmer", "coder", "coding", "software", "logician", "engineers", + "engineering", "it", "technology", "developers", "programs", "development", "developing" + ) + ), + Icon( + "ic_vue_dev_hierarchy", keywords = listOf( + "programming", "programmer", "coder", "coding", "softwares", "logician", "engineers", + "engineering", "it", "technology", "hierarchy", "developers", "programs", "development", + "developing", "structures", "relations" + ) + ), + Icon( + "ic_vue_dev_relation", keywords = listOf( + "programming", "programmer", "coder", "coding", "softwares", "logician", "engineers", + "engineering", "it", "technology", "hierarchy", "developers", "programs", "development", + "developing", "structures", "relations" + ) + ), + Icon( + "ic_vue_dev_arrow", keywords = listOf( + "programming", "programmer", "coder", "coding", "softwares", "logician", "engineers", + "engineering", "it", "technology", "hierarchy", "developers", "programs", "development", + "developing", "structures", "relations", "arrows" + ) + ), + Icon( + "ic_vue_dev_data", keywords = listOf( + "programming", "programmer", "coder", "coding", "softwares", "logician", "engineers", + "engineering", "it", "technology", "hierarchy", "developers", "programs", "development", + "developing", "structures", "relations", "data" + ) + ), + Icon( + "ic_vue_dev_hashtag", keywords = listOf( + "programming", "programmer", "coder", "coding", "softwares", "logician", "engineers", + "engineering", "it", "technology", "hierarchy", "developers", "programs", "development", + "developing", "structures", "relations", "social media", "hashtag" + ) + ), +) +// endregion + +// region Education (Vue) +private fun vueEducation(): List = listOf( + Icon( + "ic_vue_edu_planer", keywords = listOf( + "planners", + "logbooks", + "calendars", + "organizers", + "organisers", + "appointments", + "diary", + "notes", + "notebooks", + "schedules", + "agenda" + ) + ), + Icon( + "ic_vue_edu_briefcase", keywords = listOf( + "briefcases", "suitcases", "working", "careers", "professions", "appointments", + "occupations" + ) + ), + Icon( + "ic_vue_edu_award", keywords = listOf( + "awards", "badges", "prizes", "rewards", "premium", "stars" + ) + ), + Icon( + "ic_vue_edu_glass", keywords = listOf( + "glass", "cones", "vases", "flasks", "chemistry", "cones", "sciences", "potions", + "elixirs", "pharmacy", "labs", "education", "study", "learning" + ) + ), + Icon( + "ic_vue_edu_graduate_cap", keywords = listOf( + "education", "graduate", "graduation", "caps", "hats", "students", "graduates", "study", + "learning", "high school", "academy" + ) + ), + Icon( + "ic_vue_edu_calculator", keywords = listOf( + "calculates", "calculators", "calculations", "maths", "numbers", "finances" + ) + ), + Icon( + "ic_vue_edu_note", keywords = listOf( + "notes", "bills", "receipts", "recipes", "reports", "invoices", "fees", "taxes", + "expenses", "flashcards", "education", "study", "learning" + ) + ), + Icon( + "ic_vue_edu_magazine", keywords = listOf( + "magazines", "newspapers", "diary", "planners", "notes", "readings", + "education", "study", "learning", "notebooks", "textbooks", "agenda" + ) + ), + Icon( + "ic_vue_edu_pen", keywords = listOf( + "pens", "drawings", "notes", "designers", "css", "technology", "pens", "paintings", + "sketches", "study", "learning", "notes", "pencils" + ) + ), + Icon( + "ic_vue_edu_telescope", keywords = listOf( + "stars", "telescope", "sky", "planets", "astronomy", "sciences" + ) + ), + Icon( + "ic_vue_edu_book", keywords = listOf( + "notebooks", "textbooks", "planners", "logbooks", "organizers", "organisers", + "appointments", "diary", "notes", "agenda" + ) + ), + Icon( + "ic_vue_edu_ruler_pen", keywords = listOf( + "rulers", "pens", "drawings", "measure", "pencils" + ) + ), + Icon( + "ic_vue_edu_todo", keywords = listOf( + "todos", "to do", "to-do", "tasks", "check marks", "ticks", "schedules", "plans", + "agenda" + ) + ), + Icon( + "ic_vue_edu_omega", keywords = listOf( + "omega", "maths", "symbols", "signs", "letters" + ) + ), + Icon( + "ic_vue_edu_bookmark", keywords = listOf( + "bookmarks", "save", "favourites", "favorites" + ) + ), +) +// endregion + +// region Files (Vue) +private fun vueFiles(): List = listOf( + Icon( + "ic_vue_files_folder_favorite", keywords = listOf( + "bookmarks", "save", "favourites", "favorites", "folders", "files folder", "store", + "storage" + ) + ), + Icon( + "ic_vue_files_folder", keywords = listOf( + "bookmarks", "save", "folders", "files folder", "store", "storage" + ) + ), + Icon( + "ic_vue_files_folder_cloud", keywords = listOf( + "bookmarks", "save", "clouds", "folders", "files folder", "store", "storage" + ) + ), +) +// endregion + +// region Location (Vue) +private fun vueLocation(): List = listOf( + Icon( + "ic_vue_location_map1", keywords = listOf( + "maps", "atlas", "geography", "traveling", "world", "locations", "places" + ) + ), + Icon( + "ic_vue_location_map", keywords = listOf( + "maps", "atlas", "geography", "traveling", "world", "locations", "places" + ) + ), + Icon( + "ic_vue_location_location", keywords = listOf( + "maps", "atlas", "geography", "traveling", "world", "locations", "gps", "live", "places" + ) + ), + Icon( + "ic_vue_location_global", keywords = listOf( + "global", "globes", "spheres", "world", "webs", "balls", "basketball" + ) + ), + Icon( + "ic_vue_location_global_search", keywords = listOf( + "global", "globes", "spheres", "world", "webs", "searching" + ) + ), + Icon( + "ic_vue_location_routing", keywords = listOf( + "routing", "routs", "locations", "places", "directions", "gps", "maps" + ) + ), + Icon( + "ic_vue_location_discover", keywords = listOf( + "discovering", "locations", "places" + ) + ), + Icon( + "ic_vue_location_radar", keywords = listOf( + "radars", "detection", "cars", "speeds" + ) + ), + Icon( + "ic_vue_location_global_edit", keywords = listOf( + "global", "globes", "spheres", "world", "webs", "editing" + ) + ), +) +// endregion + +// region Main (Vue) +private fun vueMain(): List = listOf( + Icon( + "ic_vue_main_cake", keywords = listOf( + "birthdays", "cakes", "candles", "surprises", "bdays", "b-day" + ) + ), + Icon( + "ic_vue_main_reserve", keywords = listOf( + "reserve", "foods", "reservations", "bells", "hotels", "gourmet", "ringing" + ) + ), + Icon("ic_vue_main_archive", keywords = listOf("archives", "history")), + Icon("ic_vue_main_signpost", keywords = listOf("signposts", "signs", "directions")), + Icon( + "ic_vue_main_coffee", keywords = listOf( + "coffees", "cafes", "hot", "mornings", "wake up", "energy", "drinks", "fun", "cups", + "mugs", "glasses", "warm" + ) + ), + Icon( + "ic_vue_main_sport", keywords = listOf( + "fitness", "gym", "workout", "train", "weights", "sports", "lifting", "dumbbells", + "workouts", "work out" + ) + ), + Icon( + "ic_vue_main_notification", keywords = listOf( + "bells", "ringing", "churches", "notifications", "news" + ) + ), + Icon( + "ic_vue_main_lamp_charge", keywords = listOf( + "lamps", "bulbs", "charge", "charging", "electricity", "flashes", "sparks" + ) + ), + Icon( + "ic_vue_main_home", keywords = listOf( + "homes", "houses", "locations", "live", "remotely" + ) + ), + Icon( + "ic_vue_main_judge", keywords = listOf( + "judges", "lawyers", "legal", "businesses", "institutions", "courthouses" + ) + ), + Icon( + "ic_vue_main_timer", keywords = listOf( + "timer", "clocks", "hourglass", "sandglass" + ) + ), + Icon( + "ic_vue_main_lamp", keywords = listOf( + "lamps", "bulbs", "interiors", "lights", "lighting" + ) + ), + Icon( + "ic_vue_main_battery_charging", keywords = listOf( + "charge", "charging", "electricity", "flashes", "sparks" + ) + ), + Icon( + "ic_vue_main_calendar", keywords = listOf( + "planners", "logbooks", "calendars", "organizers", "organisers", "appointments", + "diary", "notes", "notebooks", "schedules", "agenda" + ) + ), + Icon( + "ic_vue_main_home_wifi", keywords = listOf( + "wifi", "wi-fi", "homes", "networks", "nets", "webs" + ) + ), + Icon( + "ic_vue_main_tree", keywords = listOf( + "trees", "gardens", "yards", "lawns", "woods", "christmas", "xmas", "forests" + ) + ), + Icon("ic_vue_main_battery_half", keywords = listOf("battery", "half", "charges")), + Icon( + "ic_vue_main_send", keywords = listOf( + "sending", "messages", "communication", "chatting", "chats", "play", "telegram", + "sharing", "share" + ) + ), + Icon( + "ic_vue_main_glass", keywords = listOf( + "sunglasses", "eyesight", "vision", "see", "vr", "3d" + ) + ), + Icon( + "ic_vue_main_emoji_normal", keywords = listOf( + "emojis", "normal", "happy", "chill", "joyful", "cheerful", "happiness", "good", + "faces", "emoticon", "emotions", "moods", "vibes" + ) + ), + Icon( + "ic_vue_main_share", keywords = listOf( + "shares", "businesses", "sharing", "company", "structures", "markets", "economy", + "economics", "relations", "communications", "community", "communicate", "groups", + "exchanges" + ) + ), + Icon( + "ic_vue_main_trash", keywords = listOf( + "trashes", "garbages", "junk", "rubbish", "dirt", "useless", "bin", "shit" + ) + ), + Icon( + "ic_vue_main_milk", keywords = listOf( + "milks", "bottles", "glasses", "plastic", "water", "drinks" + ) + ), + Icon( + "ic_vue_main_lifebuoy", keywords = listOf( + "lifebuoy", + "swimming pools", + "seaside", + "ocean", + "save", + "rescue", + "rescuing", + "saveguards", + "rescuers", + "life savers", + "saviors", + "beaches", + "safety", + "safeguards", + "lifeguards" + ) + ), + Icon( + "ic_vue_main_broom", keywords = listOf( + "brooms", "cleaning", "dust", "dirt", "trashes", "garbages", "sweeping", "floors", + "chores", "cleaners", "cleaning woman", "cleaning service", "home duties", "duty", + "brooming" + ) + ), + Icon( + "ic_vue_main_gift", keywords = listOf( + "gifts", "party", "celebrate", "celebrations", "presents", "donations", "donates", + "birthdays", "bdays", "b-day", "holidays" + ) + ), + Icon( + "ic_vue_main_clock", keywords = listOf( + "timer", "clocks", "appointments", "expiration", "expires", "passes", "quickly", + "alarms", "watches", "minutes", "hours", "arrows" + ) + ), + Icon( + "ic_vue_main_emoji_happy", keywords = listOf( + "emojis", "happy", "chill", "joyful", "cheerful", "happiness", "good", + "faces", "emoticon", "emotions", "moods", "vibes" + ) + ), + Icon( + "ic_vue_main_home_safe", keywords = listOf( + "safety", "homes", "houses", "insurances" + ) + ), + Icon( + "ic_vue_main_crown", keywords = listOf( + "crowns", "luxury", "vip", "top", "queens", "kings", "the best", "luxe" + ) + ), + Icon( + "ic_vue_main_cup", keywords = listOf( + "cups", "champions", "victory", "victories", "win", "prizes", "rewards", "awards" + ) + ), + Icon( + "ic_vue_main_emoji_sad", keywords = listOf( + "sad", "bad", "faces", "emoticon", "emotions", "moods", "vibes", "sick", "joyless", + "unhappy" + ) + ), + Icon("ic_vue_main_pet", keywords = listOf("pets", "dogs", "paws", "cats")), + Icon( + "ic_vue_main_flash", keywords = listOf( + "zeus", "lightning", "rains", "emergency", + "urgents", "storms", "flash", "thunders", "important", "priority", "thor", "sparks" + ) + ), +) +// endregion + +// region Media (Vue) +private fun vueMedia(): List = listOf( + Icon( + "ic_vue_media_microphone", keywords = listOf( + "microphones", "records", "recordings", "singers", "songs", "singing", "performing", + "tunes" + ) + ), + Icon( + "ic_vue_media_music", keywords = listOf( + "music", "sounds", "spotify", "singers", "songs", "hear", "fun", "party", "records", + "directing", "radios", "produce", "production", "hits", "tunes", "performing", "notes", + "listening", "recordings" + ) + ), + Icon( + "ic_vue_media_voice", keywords = listOf( + "music", "sounds", "singers", "songs", "hear", "records", "directing", "radios", + "produce", "production", "tunes", "performing", "notes", "listening", + "recordings", "voices", "speaking", "talking", "communication", "communicate" + ) + ), + Icon( + "ic_vue_media_image", keywords = listOf( + "images", "pics", "pictures", "gallery", "photos", "editing", "images", "photography" + ) + ), + Icon( + "ic_vue_media_scissors", keywords = listOf( + "scissors", "designers", "cutting", "tools", "cropping", "videos", "editing", + "video editing" + ) + ), + Icon( + "ic_vue_media_mountains", keywords = listOf( + "hike", "hikings", "mountains", "walks", "sun", "tops", "nature", "hobby", "forests", + "woods", "trees", "environments", "sports" + ) + ), + Icon( + "ic_vue_media_film", keywords = listOf( + "films", "movies", "cameras", "videos", "editing", "records", "directing", "studios", + "shows", "tvs", "streams", "acts", "actions", "produces", "productions", "acting", + "vlogs", "vlogging" + ) + ), + Icon( + "ic_vue_media_photocamera", keywords = listOf( + "cameras", "photos", "photography", "pics", "pictures", "images", "editing" + ) + ), + Icon( + "ic_vue_media_film_play", keywords = listOf( + "cameras", "videos", "editing", "films", "movies", "play", "records", "directing", + "studios", "shows", "tvs", "streams", "acts", "actions", "produces", "productions", + "acting", "vlogs", "vlogging" + ) + ), + Icon( + "ic_vue_media_camera", keywords = listOf( + "cameras", "videos", "editing", "photos", "movies", "records", "directing", "studios", + "shows", "tvs", "streams", "acts", "actions", "produces", "productions", "acting", + "films", "vlogs", "vlogging" + ) + ), + Icon( + "ic_vue_media_screenmirroring", keywords = listOf( + "screens", "mirrors", "screen mirroring", "remotely", "screen sharing", "share screen", + "meeting", "presentation" + ) + ), + Icon( + "ic_vue_media_speaker", keywords = listOf( + "speakers", "volume", "sounds", "drivers", "tunes", "listening", "talking", "speaking" + ) + ), + Icon("ic_vue_media_play", keywords = listOf("play", "start", "youtube")), + Icon( + "ic_vue_media_subtitle", keywords = listOf( + "subtitles", "texts", "articles", "chatting", "chats", "texting", "scripts" + ) + ), + Icon( + "ic_vue_media_setting", keywords = listOf( + "settings", "volume", "brightness", "editing", "contrasts", "changes", "changing" + ) + ), +) +// endregion + +// region Messages (Vue) +private fun vueMessages(): List = listOf( + Icon( + "ic_vue_messages_msg_favorite", keywords = listOf( + "messages", "chatting", "chats", "communication", "communicate", "favorites", + "favourites", "online", "love", "couples", "partnerships", "friendships", "friends", + "hearts", "partners", "sending", "family", "message box", "text box", "texting", + "bubbles", "typing", "comments" + ) + ), + Icon( + "ic_vue_messages_direct", keywords = listOf( + "messages", "chatting", "chats", "communication", "communicate", "online", "direct", + "couples", "partnerships", "friendships", "friends", "partners", "sending", "family", + "texting" + ) + ), + Icon( + "ic_vue_messages_msg_notification", keywords = listOf( + "messages", "chatting", "chats", "communication", "communicate", "online", + "notifications", "couples", "partnerships", "friendships", "friends", "circles", + "partners", "sending", "notify", "seen", "news", "missed", "family", "message box", + "text box", "texting", "bubbles" + ) + ), + Icon( + "ic_vue_messages_device_msg", keywords = listOf( + "messages", "chatting", "chats", "communication", "communicate", "online", + "notifications", "couples", "partnerships", "friendships", "friends", "partners", + "sending", "notify", "seen", "news", "missed", "family", "pc", "computers", + "message box", "text box", "texting", "bubbles", "devices", "typing", "comments" + ) + ), + Icon( + "ic_vue_messages_edit", keywords = listOf( + "messages", "chatting", "chats", "communication", "communicate", "online", + "editing", "pencils", "pens", "redactions", "texting" + ) + ), + Icon( + "ic_vue_messages_msgs", keywords = listOf( + "messages", "chatting", "chats", "communication", "communicate", "online", + "notifications", "couples", "partnerships", "friendships", "friends", "partners", + "sending", "notify", "seen", "news", "missed", "family", "message box", "text box", + "texting", "bubbles" + ) + ), + Icon( + "ic_vue_messages_msg_text", keywords = listOf( + "messages", "chatting", "chats", "communication", "communicate", "online", + "notifications", "couples", "partnerships", "friendships", "friends", "partners", + "sending", "notify", "seen", "news", "missed", "family", "message box", "text box", + "texting", "bubbles", "comments" + ) + ), + Icon( + "ic_vue_messages_letter", keywords = listOf( + "letters", "mails", "watches", "clocks", "ticks", "check marks", "bags", "news", + "missed", "notifications", "communication", "communicate", "online", "working" + ) + ), + Icon( + "ic_vue_messages_msg", keywords = listOf( + "messages", "chatting", "chats", "communication", "communicate", "online", + "notifications", "couples", "partnerships", "friendships", "friends", "partners", + "sending", "notify", "seen", "news", "missed", "family", "message box", "text box", + "texting", "bubbles", "typing", "comments" + ) + ), + Icon( + "ic_vue_messages_msg_search", keywords = listOf( + "messages", "chatting", "chats", "communication", "communicate", "online", + "notifications", "couples", "partnerships", "friendships", "friends", "partners", + "news", "missed", "family", "message box", "text box", "texting", "bubbles", "comments", + "searching" + ) + ), +) +// endregion + +// region Money (Vue) +private fun vueMoney(): List = listOf( + Icon("ic_vue_money_bitcoin_refresh",keywords=listOf("refresh","bitcoin","crypto","money")), + Icon("ic_vue_money_dollar",keywords=listOf("dollar","currency","money","symbol","usa")), + Icon("ic_vue_money_archive",keywords=listOf("archive","save","preserve","money")), + Icon("ic_vue_money_coins",keywords=listOf("money","coins")), + Icon("ic_vue_money_discount",keywords=listOf("discount","money","offer")), + Icon("ic_vue_money_receive",keywords=listOf("receive","cash-in","income","money")), + Icon("ic_vue_money_card_send",keywords=listOf("send","card","card payment","transfer","digital transfer")), + Icon("ic_vue_money_buy_crypto",keywords=listOf("buy","purchase","crypto","money","trade")), + Icon("ic_vue_money_card_bitcoin",keywords=listOf("card","crypto","bitcoin","money","crypto card")), + Icon("ic_vue_money_buy_bitcoin",keywords=listOf("purchase","bitcoin","buy bitcoin","trading","money")), + Icon("ic_vue_money_ticket_star",keywords=listOf("star","ticket","money")), + Icon("ic_vue_money_wallet",keywords=listOf("wallet","money","purse")), + Icon("ic_vue_money_send",keywords=listOf("send","money transfer","money")), + Icon("ic_vue_money_ticket_discount",keywords=listOf("discount","ticket","offer")), + Icon("ic_vue_money_wallet_cards",keywords=listOf("card holder","wallet","money")), + Icon("ic_vue_money_receipt_empty",keywords=listOf("receipt","empty","no items")), + Icon("ic_vue_money_percentage",keywords=listOf("percentage","proportion","rate","money")), + Icon("ic_vue_money_math",keywords=listOf("money","math","calculation","calculate")), + Icon("ic_vue_money_security_card",keywords=listOf("secure","shield","security","money")), + Icon("ic_vue_money_wallet_money",keywords=listOf("money","wallet","wallet coins")), + Icon("ic_vue_money_ticket",keywords=listOf("ticket","money")), + Icon("ic_vue_money_card_receive",keywords=listOf("money","receive","card")), + Icon("ic_vue_money_wallet_empty", keywords = listOf("empty","wallet","money","broke")), + Icon("ic_vue_money_transfer",keywords=listOf("transfer","remittance","money","exchange")), + Icon("ic_vue_money_card_coin",keywords=listOf("card","coin","exchange","card exchange")), + Icon("ic_vue_money_receipt_items",keywords=listOf("receipt","receipt items","list","items")), + Icon("ic_vue_money_tag", keywords = listOf("price tag","tag","money tag")), + Icon("ic_vue_money_receipt_discount",keywords= listOf("receipt","discount","coupon","money")), + Icon("ic_vue_money_card",keywords=listOf("card","debit card","credit card","money")), +) +// endregion + +// region PC (Vue) +private fun vuePC(): List = listOf( + Icon("ic_vue_pc_charging" ,keywords = listOf("charging","plug","charge","pc")), + Icon("ic_vue_pc_watch", keywords = listOf("watch","time","wristband","wristwatch")), + Icon("ic_vue_pc_headphone", keywords = listOf("headphone","music","listen","hear","sound")), + Icon("ic_vue_pc_gameboy", keywords=listOf("gaming","gameboy","player")), + Icon("ic_vue_pc_phone_call",keywords= listOf("telephone","call","ringing","incoming","phone")), + Icon("ic_vue_pc_setting",keywords=listOf("settings","configuration","setup","adjustments")), + Icon("ic_vue_pc_monitor",keywords=listOf("monitor","screen","display")), + Icon("ic_vue_pc_cpu",keywords=listOf("cpu","processor","microprocessor","computing")), + Icon("ic_vue_pc_printer",keywords=listOf("printer","copy","print")), + Icon("ic_vue_pc_bluetooth",keywords=listOf("connectivity","bluetooth","wireless","sharing")), + Icon("ic_vue_pc_wifi",keywords=listOf("wireless","wifi","signal","connectivity","network")), + Icon("ic_vue_pc_game",keywords=listOf("game","gamepad","joystick")), + Icon("ic_vue_pc_speaker",keywords=listOf("speaker","sound","audio","music")), + Icon("ic_vue_pc_phone",keywords=listOf("phone","telephone","communication")), +) +// endregion + +// region People (Vue) +private fun vuePeople(): List = listOf( + Icon("ic_vue_people_2persons",keywords=listOf("duo","pair","people")), + Icon("ic_vue_people_person_tag",keywords=listOf("tag","person tag","profile","profile tag","people")), + Icon("ic_vue_people_person_search",keywords=listOf("find","search","locate","lookup","person search")), + Icon("ic_vue_people_people",keywords=listOf("people","community","group","team")), + Icon("ic_vue_people_person",keywords=listOf("person","profile","individual")), + ) +// endregion + +// region Security (Vue) +private fun vueSecurity(): List = listOf( + Icon("ic_vue_security_eye", keywords=listOf("security", "eye", "vision", "sensor")), + Icon("ic_vue_security_shield_security", keywords=listOf("security", "shield", "protect", "sensor")), + Icon("ic_vue_security_key", keywords=listOf("security", "key")), + Icon("ic_vue_security_alarm", keywords=listOf("security", "alarm", "sensor")), + Icon("ic_vue_security_lock", keywords=listOf("security", "lock", "sensor")), + Icon("ic_vue_security_password", keywords=listOf("security", "password", "pass")), + Icon("ic_vue_security_radar", keywords=listOf("security", "radar", "sensor")), + Icon("ic_vue_security_shield_person", keywords=listOf("security", "shield", "person")), + Icon("ic_vue_security_shield", keywords=listOf("security", "shield", "sensor")), +) +// endregion + +// region Shop (Vue) +private fun vueShop(): List = listOf( + Icon("ic_vue_shop_cart", keywords=listOf("shop", "cart")), + Icon("ic_vue_shop_bag", keywords=listOf("shop", "bag")), + Icon("ic_vue_shop_barcode", keywords=listOf("shop", "barcode", "code")), + Icon("ic_vue_shop_bag1", keywords=listOf("shop", "bag")), + Icon("ic_vue_shop_shop", keywords=listOf("shop", "options")), +) +// endregion + +// region Support (Vue) +private fun vueSupport(): List = listOf( + Icon("ic_vue_support_star", keywords=listOf("support", "star", "support reaction", "reaction")), + Icon("ic_vue_support_medal", keywords=listOf("support", "medal", "support reaction", "reaction")), + Icon("ic_vue_support_dislike", keywords=listOf("support", "dislike", "support reaction", "reaction")), + Icon("ic_vue_support_like_dislike", keywords=listOf("support", "like", "dislike", "like dislike", "support reaction", "reaction")), + Icon("ic_vue_support_smileys", keywords=listOf("support", "smileys", "support reaction", "reaction")), + Icon("ic_vue_support_heart", keywords=listOf("support", "heart", "support reaction", "reaction")), + Icon("ic_vue_support_like", keywords=listOf("support", "like", "support reaction", "reaction")), +) +// endregion + +// region Transport (Vue) +private fun vueTransport(): List = listOf( + Icon( + "ic_vue_transport_bus", keywords = listOf( + "microbus", "minibus", "minivan", "bus", "public transport", + "travel", "journey", "utility vehicle" + ) + ), + Icon( + "ic_vue_transport_airplane", keywords = listOf( + "airliner", "air taxi", "aircraft", "airship", "jet", + "trijet", "aerospace plane", "rocket plane", "bomber", "warplane", + "biplane", "lightplane", "tilt-rotor", "triplane", "public transport" + ) + ), + Icon( + "ic_vue_transport_train", keywords = listOf( + "caravan", "track", "chain", "concatenation", "tail", "trail", + "rail line", "locomotive", "railcar", "freight train", "railway", + "cargo", "diesel locomotive", "passenger train", "public transport", + "electric locomotive", "wagon train" + ) + ), + Icon( + "ic_vue_transport_ship", keywords = listOf( + "warship", "cargo ship", "ferry", "vessel", "sail", "watercraft", "transport", + "cruise ship", "troopship", "passenger ship", "fleet", "yacht", "navy" + ) + ), + Icon( + "ic_vue_transport_gas", keywords = listOf( + "incompressible", "chemical weapon", "compressibility", "intermolecular forces", + "covalent bond", "kerosine", "octanes", "liquification", "weather", "methane", + "oxygen", "hydrogen", "gasoline", "petrol", "carbon dioxide", "neon", "plasma" + ) + ), + Icon( + "ic_vue_transport_car", keywords = listOf( + "motor vehicle", "wheel", "automobile", "van", + "vehicle", "passenger", "internal combustion engine", + "jeep", "cab", "sedan", "hatchback", "taxi", + "air pollution", "climate change", "toyota" + ) + ), + Icon( + "ic_vue_transport_car_wash", keywords = listOf( + "automobile", "carwashing", "carwasher", "become dirty", "carwash", + "rinse", "cleanse", "washcloth", "disinfect", "sanitation" + ) + ), +) +// endregion + +// region Type (Vue) +private fun vueType(): List = listOf( + Icon( + "ic_vue_type_link2", keywords = listOf( + "connection", "connect", "contact", "tie", "link2", + "attach", "chain", "interconnect", "hyperlink" + ) + ), + Icon( + "ic_vue_type_text", keywords = listOf( + "book", "textbook", "passage", "page", "words", "language", + "word", "paragraph", "chapter", "booklet", "dictionary", + "reference", "read", "written", "phrase", "passages" + ) + ), + Icon( + "ic_vue_type_paperclip", + keywords = listOf("clip", "paper clip", "steel", "paper", "managing files") + ), + Icon( + "ic_vue_type_textalign_left", keywords = listOf( + "content", "document", "idea", "paragraph", "left", + "quotation", "formating", "body", "context", "align left" + ) + ), + Icon( + "ic_vue_type_translate", keywords = listOf( + "paraphrase", "interpret", "understand", "language", "writing", + "translator", "change", "meaning", "literal translation", + "english language", "grammar", "dictionary" + ) + ), + Icon( + "ic_vue_type_textalign_right", keywords = listOf( + "content", "document", "paragraph", "quotation", "right", + "formating", "body", "context", "align right" + ) + ), + Icon( + "ic_vue_type_link", keywords = listOf( + "connection", "connect", "contact", "tie", "link", + "attach", "chain", "interconnect", "hyperlink" + ) + ), + Icon( + "ic_vue_type_textalign_center", + keywords = listOf( + "core", + "centre", + "central", + "middle", + "rivet", + "midpoint", + "align center" + ) + ), + Icon( + "ic_vue_type_textalign_justifycenter", + keywords = listOf("distribute", "inline", "central", "middle", "align justifycenter") + ), +) +// endregion + +// region Weather (Vue) +private fun vueWeather(): List = listOf( + Icon( + "ic_vue_weather_wind", + keywords = listOf( + "blow", "gale", "gust", "headwind", "tailwind", "tempest", "wind", + "tornado", "windstorm", "breath", "breeze", "air", "typhoon" + ) + ), + Icon( + "ic_vue_weather_cloud", + keywords = listOf("vapor", "cloud", "veil", "fogginess", "frost", "thunderhead") + ), + Icon( + "ic_vue_weather_flash", + keywords = listOf( + "binge", "jag", "boost", "increase", "pickup", "upswing", "epidemic", + "eruption", "explosion", "flood", "rush", "surge", "uproar", "flash" + ) + ), + Icon( + "ic_vue_weather_moon", keywords = listOf( + "moment", "crescent", "half-moon", "celestial body", "full moon", + "heavenly body", "new moon", "old moon", "orb of night" + ) + ), + Icon( + "ic_vue_weather_drop", keywords = listOf( + "bead", "bit", "bubble", "dash", "dewdrop", + "driblet", "drip", "droplet", "pearl", + "splash", "tear", "teardrop" + ) + ), + Icon( + "ic_vue_weather_cold", keywords = listOf( + "bleak", "chilled", "cool", "crisp", "frosty", "frozen", "icy", "intense", "snowy", + "wintry", "Siberian", "arctic", "chill", "icebox", "stinging", "below freezing", + "sharp", "below zero", "glacial", "have goose bumps", "numbing", "shivery" + ) + ), + Icon( + "ic_vue_weather_sun", keywords = listOf( + "star", "sunlight", "bask", "daylight", "tan", + "flare", "shine", "sol", "sunrise", "aubade" + ) + ), +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/icon/picker/IconPickerModal.kt b/core/ui/src/main/java/com/ivy/core/ui/icon/picker/IconPickerModal.kt new file mode 100644 index 0000000..56d0792 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/icon/picker/IconPickerModal.kt @@ -0,0 +1,244 @@ +package com.ivy.core.ui.icon.picker + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.R +import com.ivy.core.ui.data.icon.IconSize +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.core.ui.data.icon.iconId +import com.ivy.core.ui.icon.ItemIcon +import com.ivy.core.ui.icon.picker.IconPickerViewModel.Companion.ICONS_PER_ROW +import com.ivy.core.ui.icon.picker.data.SectionUi +import com.ivy.core.ui.icon.toDp +import com.ivy.data.ItemIconId +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.rememberDynamicContrast +import com.ivy.design.l1_buildingBlocks.* +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Choose +import com.ivy.design.l2_components.modal.components.Search +import com.ivy.design.l2_components.modal.components.SearchButton +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.hiltViewModelPreviewSafe +import com.ivy.design.util.thenIf + +private val iconSize = IconSize.M +private val iconPadding = 12.dp + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun BoxScope.IconPickerModal( + modal: IvyModal, + level: Int = 1, + initialIcon: ItemIcon?, + color: Color, + onIconPick: (ItemIconId) -> Unit +) { + val viewModel: IconPickerViewModel? = hiltViewModelPreviewSafe() + val state = viewModel?.uiState?.collectAsState()?.value ?: previewState() + + var selectedIcon by remember(initialIcon) { mutableStateOf(initialIcon?.iconId()) } + var searchBarVisible by remember(initialIcon, color) { mutableStateOf(false) } + + val keyboardController = LocalSoftwareKeyboardController.current + val resetSearch = { + keyboardController?.hide() + viewModel?.onEvent(IconPickerEvent.Search(query = "")) + searchBarVisible = false + } + + Modal( + modal = modal, + level = level, + actions = { + SearchButton(searchBarVisible = searchBarVisible) { + if (searchBarVisible) resetSearch() else searchBarVisible = true + } + SpacerHor(width = 8.dp) + Choose { + selectedIcon?.let(onIconPick) + keyboardController?.hide() + modal.hide() + } + } + ) { + Search( + searchBarVisible = searchBarVisible, + initialSearchQuery = state.searchQuery, + searchHint = "Search by words (car, home, tech)", + resetSearch = resetSearch, + onSearch = { viewModel?.onEvent(IconPickerEvent.Search(it)) }, + ) { + item(key = "ic_picker_title") { + this@Modal.Title(text = stringResource(R.string.choose_icon)) + } + sections( + sections = state.sections, + selectedIcon = selectedIcon, + color = color, + onIconSelect = { + selectedIcon = it + onIconPick(it) + keyboardController?.hide() + modal.hide() + } + ) + item(key = "ic_picker_last_spacer") { SpacerVer(height = 48.dp) } + } + } +} + +private fun LazyListScope.sections( + sections: List, + selectedIcon: ItemIconId?, + color: Color, + onIconSelect: (ItemIconId) -> Unit +) { + sections.forEach { + section( + section = it, + selectedIcon = selectedIcon, + color = color, + onIconSelect = onIconSelect + ) + } +} + +// region Section +private fun LazyListScope.section( + section: SectionUi, + selectedIcon: ItemIconId?, + color: Color, + onIconSelect: (ItemIconId) -> Unit +) { + item(key = "section_${section.name}_${section.iconRows.size}") { + SpacerVer(height = 24.dp) + SectionDivider(title = section.name) + SpacerVer(height = 12.dp) + } + items( + items = section.iconRows, + key = { "ic_row_${it.first().iconId()}" } + ) { iconRow -> + IconsRow( + icons = iconRow, + selectedIcon = selectedIcon, + color = color, + onIconSelect = onIconSelect + ) + SpacerVer(height = 12.dp) + } +} + +@Composable +private fun SectionDivider(title: String) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + DividerW() + SpacerHor(width = 16.dp) + B1(text = title) + SpacerHor(width = 16.dp) + DividerW() + } +} +// endregion + +// region Icons row +@Composable +private fun IconsRow( + icons: List, + selectedIcon: ItemIconId?, + color: Color, + onIconSelect: (ItemIconId) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + SpacerWeight(weight = 1f) + for (icon in icons) { + val iconId = icon.iconId() + key("ic_item_$iconId") { + IconItem( + icon = icon, + selected = selectedIcon == iconId, + color = color, + ) { + // on click: + iconId?.let(onIconSelect) + } + SpacerWeight(weight = 1f) + } + } + MissingIconsInRowSpace(missingIcons = ICONS_PER_ROW - icons.size) + } +} + +@Composable +private fun RowScope.MissingIconsInRowSpace( + missingIcons: Int +) { + if (missingIcons > 0) { + val iconSize = iconSize.toDp() + (iconPadding * missingIcons) + SpacerHor(width = iconSize * missingIcons) + SpacerWeight(weight = 1f * missingIcons) + } +} + +@Composable +private fun IconItem( + icon: ItemIcon, + selected: Boolean, + color: Color, + onClick: () -> Unit, +) { + ItemIcon( + modifier = Modifier + .clip(UI.shapes.circle) + .border(2.dp, if (selected) color else UI.colors.medium, UI.shapes.circle) + .thenIf(selected) { background(color, UI.shapes.circle) } + .clickable(onClick = onClick) + .padding(all = iconPadding) + .testTag(icon.iconId() ?: "no icon"), + itemIcon = icon, + size = iconSize, + tint = if (selected) rememberDynamicContrast(color) else UI.colorsInverted.medium + ) +} +// endregion + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + + } +} + +private fun previewState() = IconPickerStateUi( + sections = emptyList(), // TODO: Provide preview state + searchQuery = "" +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/icon/picker/IconPickerStateUi.kt b/core/ui/src/main/java/com/ivy/core/ui/icon/picker/IconPickerStateUi.kt new file mode 100644 index 0000000..1464c84 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/icon/picker/IconPickerStateUi.kt @@ -0,0 +1,10 @@ +package com.ivy.core.ui.icon.picker + +import androidx.compose.runtime.Immutable +import com.ivy.core.ui.icon.picker.data.SectionUi + +@Immutable +internal data class IconPickerStateUi( + val sections: List, + val searchQuery: String, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/icon/picker/IconPickerViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/icon/picker/IconPickerViewModel.kt new file mode 100644 index 0000000..19ed7fe --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/icon/picker/IconPickerViewModel.kt @@ -0,0 +1,89 @@ +package com.ivy.core.ui.icon.picker + +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.pure.ui.groupByRows +import com.ivy.core.ui.action.ItemIconOptionalAct +import com.ivy.core.ui.icon.picker.data.Icon +import com.ivy.core.ui.icon.picker.data.SectionUi +import com.ivy.core.ui.icon.picker.data.SectionUnverified +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import javax.inject.Inject + + +@HiltViewModel +internal class IconPickerViewModel @Inject constructor( + private val itemIconOptionalAct: ItemIconOptionalAct +) : SimpleFlowViewModel() { + companion object { + const val ICONS_PER_ROW = 4 + } + + override val initialUi = IconPickerStateUi( + sections = emptyList(), + searchQuery = "" + ) + + private val searchQuery = MutableStateFlow("") + + override val uiFlow: Flow = sectionsUiFlow().map { sections -> + IconPickerStateUi( + sections = sections, + searchQuery = searchQuery.value + ) + } + + private fun sectionsUiFlow(): Flow> = sectionsFlow().map { sections -> + sections.mapNotNull { section -> + // Transform ItemIconId -> ItemIcon and filter empty sections + val itemIcons = section.icons.mapNotNull { + itemIconOptionalAct(it.iconId) + } + if (itemIcons.isNotEmpty()) { + SectionUi( + name = section.name, + iconRows = groupByRows(itemIcons, itemsPerRow = ICONS_PER_ROW), + ) + } else null + } + } + + @OptIn(FlowPreview::class) + private fun sectionsFlow(): Flow> = searchQuery + .debounce(100) + .map { query -> + val sections = pickerItems() + val normalizedQuery = query.trim().lowercase().takeIf { it.isNotEmpty() } + if (normalizedQuery != null) { + listOf( + SectionUnverified( + name = "Search result", + icons = sections.flatMap { section -> + section.icons.filter { icon -> + passesSearch(icon = icon, query = normalizedQuery) + } + } + ) + ) + } else sections + } + + private fun passesSearch(icon: Icon, query: String): Boolean = + // Icon must have at least one keyword that contains the search query + icon.keywords.any { keyword -> keyword.contains(query) } + + + // region Event Handling + override suspend fun handleEvent(event: IconPickerEvent) = when (event) { + is IconPickerEvent.Search -> handleSearchQuery(event) + } + + private fun handleSearchQuery(event: IconPickerEvent.Search) { + searchQuery.value = event.query.lowercase() + } + // endregion +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/icon/picker/data/Icon.kt b/core/ui/src/main/java/com/ivy/core/ui/icon/picker/data/Icon.kt new file mode 100644 index 0000000..926d3ff --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/icon/picker/data/Icon.kt @@ -0,0 +1,8 @@ +package com.ivy.core.ui.icon.picker.data + +import com.ivy.data.ItemIconId + +internal data class Icon( + val iconId: ItemIconId, + val keywords: List = emptyList(), +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/icon/picker/data/SectionUi.kt b/core/ui/src/main/java/com/ivy/core/ui/icon/picker/data/SectionUi.kt new file mode 100644 index 0000000..0a703c1 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/icon/picker/data/SectionUi.kt @@ -0,0 +1,10 @@ +package com.ivy.core.ui.icon.picker.data + +import androidx.compose.runtime.Immutable +import com.ivy.core.ui.data.icon.ItemIcon + +@Immutable +data class SectionUi( + val name: String, + val iconRows: List> +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/icon/picker/data/SectionUnverified.kt b/core/ui/src/main/java/com/ivy/core/ui/icon/picker/data/SectionUnverified.kt new file mode 100644 index 0000000..0dc47b5 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/icon/picker/data/SectionUnverified.kt @@ -0,0 +1,6 @@ +package com.ivy.core.ui.icon.picker.data + +internal data class SectionUnverified( + val name: String, + val icons: List +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/modal/RateModal.kt b/core/ui/src/main/java/com/ivy/core/ui/modal/RateModal.kt new file mode 100644 index 0000000..e919167 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/modal/RateModal.kt @@ -0,0 +1,70 @@ +package com.ivy.core.ui.modal + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.ivy.core.ui.amount.AmountModal +import com.ivy.data.Value +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.H2Second +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.previewModal +import com.ivy.design.util.IvyPreview + +@Composable +fun BoxScope.RateModal( + modal: IvyModal, + rate: Double, + fromCurrency: String, + toCurrency: String, + level: Int = 1, + key: String = "default", + onRateChange: (Double) -> Unit, +) { + AmountModal( + modal = modal, + level = level, + key = key, + contentAbove = { + SpacerVer(height = 24.dp) + H2Second( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = "$fromCurrency to $toCurrency", + color = UI.colors.primary, + textAlign = TextAlign.Center, + ) + SpacerVer(height = 24.dp) + }, + initialAmount = Value( + amount = rate, + currency = "", + ), + onAmountEnter = { + onRateChange(it.amount) + }, + ) +} + + +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = previewModal() + RateModal( + modal = modal, + rate = 1.95, + fromCurrency = "EUR", + toCurrency = "BGN", + onRateChange = {}, + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/modal/ViewModelModal.kt b/core/ui/src/main/java/com/ivy/core/ui/modal/ViewModelModal.kt new file mode 100644 index 0000000..8a53523 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/modal/ViewModelModal.kt @@ -0,0 +1,187 @@ +package com.ivy.core.ui.modal + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.* +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.ivy.core.domain.FlowViewModel +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.mediumBlur +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l1_buildingBlocks.SpacerWeight +import com.ivy.design.l2_components.modal.CloseButton +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.scope.ModalActionsScope +import com.ivy.design.l2_components.modal.scope.ModalActionsScopeImpl +import com.ivy.design.l2_components.modal.scope.ModalScope +import com.ivy.design.l2_components.modal.scope.ModalScopeImpl +import com.ivy.design.util.* + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun BoxScope.ViewModelModal( + modal: IvyModal, + // TODO: Fix potential recomposition problems + provideViewModel: @Composable () -> FlowViewModel, + previewState: @Composable () -> UiState, + actions: @Composable ModalActionsScope.( + state: UiState, + onEvent: (Event) -> Unit, + ) -> Unit, + keyboardShiftsContent: Boolean = true, + level: Int = 1, + content: @Composable ModalScope.( + state: UiState, + onEvent: (Event) -> Unit, + ) -> Unit +) { + val visible by modal.visibilityState + + AnimatedVisibility( + modifier = Modifier + .fillMaxSize() + .zIndex(1_000f * level), + visible = visible, + enter = fadeIn(), + exit = fadeOut() + ) { + val keyboardController = LocalSoftwareKeyboardController.current + Spacer( + modifier = Modifier + .fillMaxSize() + .background(mediumBlur()) + .testTag("modal_outside_blur") + .clickable( + onClick = { + keyboardController?.hide() + modal.hide() + }, + enabled = visible + ) + ) + } + + AnimatedVisibility( + modifier = Modifier + .align(Alignment.BottomCenter) + .zIndex(1_100f * level), + visible = visible, + enter = slideInVertically( + initialOffsetY = { fullHeight: Int -> fullHeight } + ), + exit = slideOutVertically( + targetOffsetY = { fullHeight: Int -> fullHeight } + ) + ) { + val systemBottomPadding = systemPaddingBottom() + val keyboardShown by keyboardShownState() + val keyboardShownInset = keyboardPadding() + val paddingBottom = if (keyboardShiftsContent) { + animateDpAsState( + targetValue = if (keyboardShown) + keyboardShownInset else systemBottomPadding, + ).value + } else systemBottomPadding + + Column( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(top = 24.dp) // 24 dp from the status bar (top) + .background(UI.colors.pure, UI.shapes.roundedTop) + .clip(UI.shapes.roundedTop) + .consumeClicks() // don't close the modal when clicking on the empty space inside + .padding(bottom = paddingBottom) + ) { + val viewModel = if (isInPreview()) null else provideViewModel() + val state = viewModel?.uiState?.collectAsState()?.value ?: previewState() + + BackHandler(enabled = modal.visibilityState.value) { + modal.hide() + } + + val modalScope = remember { ModalScopeImpl(this) } + with(modalScope) { + content(state) { viewModel?.onEvent(it) } + } + + val keyboardController = LocalSoftwareKeyboardController.current + ModalActionsRow( + Actions = { + actions(state, onEvent = { viewModel?.onEvent(it) }) + }, + onClose = { + keyboardController?.hide() + modal.hide() + }, + ) + SpacerVer(height = 12.dp) + } + } +} + +@Composable +private fun ModalActionsRow( + Actions: @Composable ModalActionsScope.() -> Unit, + modifier: Modifier = Modifier, + onClose: () -> Unit, +) { + RowWithLine( + // don't add horizontal padding because it'll break the line + modifier = modifier.padding(top = 4.dp), + ) { + SpacerHor(width = 16.dp) + CloseButton( + modifier = Modifier.testTag("modal_close_button"), + onClick = onClose + ) + SpacerWeight(weight = 1f) + val actionsScope = remember { ModalActionsScopeImpl(this) } + with(actionsScope) { + Actions() + } + SpacerHor(width = 16.dp) + } + +} + +@Composable +private fun RowWithLine( + modifier: Modifier = Modifier, + lineColor: Color = UI.colors.medium, + Content: @Composable RowScope.() -> Unit +) { + Row( + modifier = modifier + .fillMaxWidth() + .drawBehind { + val height = this.size.height + val width = this.size.width + + drawLine( + color = lineColor, + strokeWidth = 2.dp.toPx(), + start = Offset(x = 0f, y = height / 2), + end = Offset(x = width, y = height / 2) + ) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Content() + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/time/FormatTime.kt b/core/ui/src/main/java/com/ivy/core/ui/time/FormatTime.kt new file mode 100644 index 0000000..9a0a07e --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/time/FormatTime.kt @@ -0,0 +1,54 @@ +package com.ivy.core.ui.time + +import android.content.Context +import com.ivy.common.time.format +import com.ivy.common.time.provider.TimeProvider +import com.ivy.resources.R +import java.time.LocalDateTime + +fun LocalDateTime.formatNicely( + context: Context, + timeProvider: TimeProvider, + includeWeekDay: Boolean = true, +): String { + val today = timeProvider.dateNow() + val isThisYear = today.year == this.year + + val patternNoWeekDay = "dd MMM" + + if (!includeWeekDay) { + return if (isThisYear) { + this.format(patternNoWeekDay) + } else { + this.format("dd MMM, yyyy") + } + } + + return when (this.toLocalDate()) { + today -> { + context.getString( + R.string.today_date, + this.format(patternNoWeekDay) + ) + } + today.minusDays(1) -> { + context.getString( + R.string.yesterday_date, + this.format(patternNoWeekDay) + ) + } + today.plusDays(1) -> { + context.getString( + R.string.tomorrow_date, + this.format(patternNoWeekDay) + ) + } + else -> { + if (isThisYear) { + this.format("EEE, dd MMM") + } else { + this.format("dd MMM, yyyy") + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/time/PeriodButton.kt b/core/ui/src/main/java/com/ivy/core/ui/time/PeriodButton.kt new file mode 100644 index 0000000..8163132 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/time/PeriodButton.kt @@ -0,0 +1,72 @@ +package com.ivy.core.ui.time + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.ivy.core.ui.R +import com.ivy.core.ui.data.period.SelectedPeriodUi +import com.ivy.core.ui.data.period.dummyMonthUi +import com.ivy.core.ui.data.period.dummyRangeUi +import com.ivy.core.ui.time.handling.SelectPeriodEvent +import com.ivy.core.ui.time.handling.SelectedPeriodViewModel +import com.ivy.core.ui.uiStatePreviewSafe +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.hiltViewModelPreviewSafe +import com.ivy.wallet.utils.horizontalSwipeListener + +@Composable +fun PeriodButton( + periodModal: IvyModal, + modifier: Modifier = Modifier, +) { + val viewModel: SelectedPeriodViewModel? = hiltViewModelPreviewSafe() + val state = uiStatePreviewSafe(viewModel, preview = ::previewState) + + IvyButton( + modifier = modifier.horizontalSwipeListener( + sensitivity = 75, + onSwipeLeft = { + // next month + viewModel?.onEvent(SelectPeriodEvent.ShiftForward) + }, + onSwipeRight = { + // previous month + viewModel?.onEvent(SelectPeriodEvent.ShiftBackward) + } + ), + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = state.selectedPeriodUi.periodBtnText, + icon = R.drawable.ic_round_calendar_month_24, + ) { + periodModal.show() + } +} + + +// region Previews +@Preview +@Composable +private fun Preview() { + ComponentPreview { + PeriodButton(periodModal = rememberIvyModal()) + } +} + +private fun previewState() = SelectedPeriodViewModel.UiState( + startDayOfMonth = 1, + selectedPeriodUi = SelectedPeriodUi.Monthly( + periodBtnText = "September", + month = dummyMonthUi(), + rangeUi = dummyRangeUi(), + ), + months = emptyList(), +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/time/PeriodModal.kt b/core/ui/src/main/java/com/ivy/core/ui/time/PeriodModal.kt new file mode 100644 index 0000000..cf6e285 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/time/PeriodModal.kt @@ -0,0 +1,455 @@ +package com.ivy.core.ui.time + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ivy.common.time.atEndOfDay +import com.ivy.common.time.dateNowLocal +import com.ivy.core.ui.R +import com.ivy.core.ui.data.period.* +import com.ivy.core.ui.modal.ViewModelModal +import com.ivy.core.ui.rootScreen +import com.ivy.core.ui.time.handling.SelectPeriodEvent +import com.ivy.core.ui.time.handling.SelectedPeriodViewModel +import com.ivy.data.time.TimeRange +import com.ivy.data.time.TimeUnit +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.B1 +import com.ivy.design.l1_buildingBlocks.DividerHor +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.components.Done +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +// TODO: Re-work and make optimal + +@Composable +fun BoxScope.PeriodModal( + modal: IvyModal, +) { + val moreOptionsModal = rememberIvyModal() + + ViewModelModal( + modal = modal, + provideViewModel = { hiltViewModel() }, + previewState = { previewState() }, + actions = { _, _ -> + Done { + modal.hide() + } + } + ) { state, onEvent -> + ChooseMonth( + months = state.months, + selected = state.selectedPeriodUi, + ) { + onEvent(SelectPeriodEvent.Monthly(it)) + } + + SpacerVer(height = 16.dp) + DividerHor(width = 1.dp, color = UI.colors.neutral) + SpacerVer(height = 12.dp) + + FromToRange(selected = state.selectedPeriodUi, onEvent = onEvent) + + SpacerVer(height = 16.dp) + DividerHor(width = 1.dp, color = UI.colors.neutral) + SpacerVer(height = 12.dp) + + MoreOptions( + selected = state.selectedPeriodUi, + onEvent = onEvent, + onShowMoreOptionsModal = { + moreOptionsModal.show() + } + ) + + SpacerVer(height = 48.dp) + } + + MoreOptionsModal( + periodModal = modal, + moreOptionsModal = moreOptionsModal, + ) +} + + +// region Choose month +@Composable +private fun ChooseMonth( + months: List, + selected: SelectedPeriodUi, + onMonthSelected: (MonthUi) -> Unit, +) { + SpacerVer(height = 16.dp) + val selectedMonthly = selected is SelectedPeriodUi.Monthly + SectionText( + text = "Month", + selected = selectedMonthly, + modifier = Modifier.padding(start = 24.dp), + ) + SpacerVer(height = 8.dp) + + val state = rememberLazyListState() + val selectedMonth = (selected as? SelectedPeriodUi.Monthly)?.month + var firstTimeScrolling by remember { mutableStateOf(true) } + LaunchedEffect(selected) { + if (selectedMonth != null) { + val selectedMonthIndex = withContext(Dispatchers.Default) { + months.indexOf(selectedMonth).takeIf { it != -1 } + } + if (selectedMonthIndex != null) { + if (firstTimeScrolling) { + state.scrollToItem(selectedMonthIndex) + firstTimeScrolling = false + } else { + state.animateScrollToItem(selectedMonthIndex) + } + } + } + } + + LazyRow( + state = state + ) { + item { + SpacerHor(width = 8.dp) + } + items(months) { month -> + MonthItem(month = month, selected = month == selectedMonth) { + onMonthSelected(it) + } + SpacerHor(width = 8.dp) + } + } +} + +@Composable +private fun MonthItem( + month: MonthUi, + selected: Boolean, + onClick: (MonthUi) -> Unit +) { + IvyButton( + size = ButtonSize.Small, + visibility = if (selected) Visibility.High else Visibility.Medium, + feeling = if (selected) Feeling.Positive else Feeling.Neutral, + text = if (month.currentYear) month.fullName else "${month.fullName}, ${month.year}", + icon = null + ) { + onClick(month) + } +} +// endregion + +// region From - To +@Composable +private fun FromToRange( + selected: SelectedPeriodUi, + onEvent: (SelectPeriodEvent) -> Unit, +) { + val periodUi = selected.rangeUi + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val selectedCustom = selected is SelectedPeriodUi.CustomRange || + selected is SelectedPeriodUi.InTheLast + val rootScreen = rootScreen() + PeriodClosureColumn( + label = "From", + dateText = periodUi.fromText, + selectedCustom = selectedCustom, + ) { + rootScreen.datePicker( + minDate = null, + maxDate = periodUi.range.to.toLocalDate(), + initialDate = periodUi.range.from.toLocalDate() + ) { pickedDate -> + onEvent( + SelectPeriodEvent.CustomRange( + range = TimeRange( + from = pickedDate.atStartOfDay(), + to = periodUi.range.to + ) + ) + ) + } + } + SpacerHor(width = 16.dp) + PeriodClosureColumn( + label = "To", + dateText = periodUi.toText, + selectedCustom = selectedCustom, + ) { + rootScreen.datePicker( + minDate = periodUi.range.from.toLocalDate(), + maxDate = null, + initialDate = periodUi.range.to.toLocalDate() + ) { pickedDate -> + onEvent( + SelectPeriodEvent.CustomRange( + range = TimeRange( + from = periodUi.range.from, + to = pickedDate.atEndOfDay() + ) + ) + ) + } + } + } +} + +@Composable +private fun RowScope.PeriodClosureColumn( + selectedCustom: Boolean, + label: String, + dateText: String, + onClick: () -> Unit +) { + Column( + modifier = Modifier.weight(1f) + ) { + SectionText( + text = label, + selected = selectedCustom, + modifier = Modifier.padding(start = 16.dp), + ) + SpacerVer(height = 4.dp) + DateButton( + dateText = dateText, + onClick = onClick, + ) + } +} + +@Composable +private fun DateButton( + modifier: Modifier = Modifier, + dateText: String, + onClick: () -> Unit +) { + IvyButton( + modifier = modifier, + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Neutral, + text = dateText, + icon = R.drawable.ic_round_calendar_month_24, + onClick = onClick + ) + +} +// endregion + +// region More Options +@Composable +private fun MoreOptions( + selected: SelectedPeriodUi, + onEvent: (SelectPeriodEvent) -> Unit, + onShowMoreOptionsModal: () -> Unit, +) { + val selectedMore = selected is SelectedPeriodUi.AllTime + SectionText( + text = "More options", + selected = selectedMore, + modifier = Modifier.padding(start = 24.dp), + ) + SpacerVer(height = 8.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IvyButton( + modifier = Modifier.weight(1f), + size = ButtonSize.Big, + visibility = if (selected is SelectedPeriodUi.AllTime) + Visibility.High else Visibility.Medium, + feeling = Feeling.Positive, + text = "All-time", + icon = R.drawable.ic_baseline_all_inclusive_24 + ) { + onEvent(SelectPeriodEvent.AllTime) + } + SpacerHor(width = 8.dp) + IvyButton( + modifier = Modifier.weight(1f), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Negative, + text = "Reset", + icon = R.drawable.ic_round_undo_24 + ) { + onEvent(SelectPeriodEvent.ResetToCurrentPeriod) + } + } + SpacerVer(height = 8.dp) + IvyButton( + modifier = Modifier.padding(horizontal = 8.dp), + size = ButtonSize.Big, + visibility = Visibility.Low, + feeling = Feeling.Neutral, + text = "See more", + icon = R.drawable.ic_round_expand_less_24, + onClick = onShowMoreOptionsModal + ) +} +// endregion + +// region More Options modal +@Composable +private fun BoxScope.MoreOptionsModal( + periodModal: IvyModal, + moreOptionsModal: IvyModal, +) { + ViewModelModal( + modal = moreOptionsModal, + provideViewModel = { hiltViewModel() }, + previewState = { previewState() }, + actions = { _, _ -> } + ) { _, onEvent -> + Title(text = "More Options") + SpacerVer(height = 24.dp) + val thisYear = remember { dateNowLocal().year.toString() } + val lastYear = remember { (dateNowLocal().year - 1).toString() } + LazyColumn { + val onOptionItemClick = { event: SelectPeriodEvent -> + onEvent(event) + moreOptionsModal.hide() + periodModal.hide() + } + optionItem( + text = thisYear, + event = SelectPeriodEvent.ThisYear, + onClick = onOptionItemClick + ) + optionItem( + text = lastYear, + event = SelectPeriodEvent.LastYear, + onClick = onOptionItemClick + ) + optionItem( + text = "Last 6 months", + event = SelectPeriodEvent.InTheLast(n = 6, unit = TimeUnit.Month), + onClick = onOptionItemClick + ) + optionItem( + text = "Last 3 months", + event = SelectPeriodEvent.InTheLast(n = 3, unit = TimeUnit.Month), + onClick = onOptionItemClick + ) + optionItem( + text = "Last 30 days", + event = SelectPeriodEvent.InTheLast(n = 30, unit = TimeUnit.Day), + onClick = onOptionItemClick + ) + optionItem( + text = "Last 15 days", + event = SelectPeriodEvent.InTheLast(n = 15, unit = TimeUnit.Day), + onClick = onOptionItemClick + ) + optionItem( + text = "Last 7 days", + event = SelectPeriodEvent.InTheLast(n = 7, unit = TimeUnit.Day), + onClick = onOptionItemClick + ) + optionItem( + text = "Last 3 days", + event = SelectPeriodEvent.InTheLast(n = 3, unit = TimeUnit.Day), + onClick = onOptionItemClick + ) + } + SpacerVer(height = 48.dp) + } +} + +private fun LazyListScope.optionItem( + text: String, + event: SelectPeriodEvent, + onClick: (SelectPeriodEvent) -> Unit +) { + item { + MoreOptionsButton(text = text) { + onClick(event) + } + SpacerVer(height = 8.dp) + } +} + +@Composable +private fun MoreOptionsButton( + text: String, + onClick: () -> Unit +) { + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = text, + icon = null, + onClick = onClick + ) +} +// endregion + +// region Components +@Composable +private fun SectionText( + text: String, + selected: Boolean, + modifier: Modifier = Modifier +) { + B1( + text = text, + modifier = modifier, + fontWeight = if (selected) FontWeight.ExtraBold else FontWeight.Bold, + color = if (selected) UI.colors.primary else UI.colorsInverted.pure + ) +} +// endregion + +// region Previews +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + PeriodModal( + modal = modal, + ) + } +} + +@Composable +private fun previewState() = SelectedPeriodViewModel.UiState( + startDayOfMonth = 1, + months = monthsList(LocalContext.current, year = dateNowLocal().year, currentYear = true), + selectedPeriodUi = SelectedPeriodUi.Monthly( + periodBtnText = "Sep", + month = dummyMonthUi(), + rangeUi = dummyRangeUi() + ) +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/time/handling/SelectPeriodEvent.kt b/core/ui/src/main/java/com/ivy/core/ui/time/handling/SelectPeriodEvent.kt new file mode 100644 index 0000000..a89fe4c --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/time/handling/SelectPeriodEvent.kt @@ -0,0 +1,25 @@ +package com.ivy.core.ui.time.handling + +import com.ivy.core.ui.data.period.MonthUi +import com.ivy.data.time.TimeRange +import com.ivy.data.time.TimeUnit + +sealed interface SelectPeriodEvent { + data class Monthly(val month: MonthUi) : SelectPeriodEvent + + data class InTheLast(val n: Int, val unit: TimeUnit) : SelectPeriodEvent + + object AllTime : SelectPeriodEvent + + data class CustomRange(val range: TimeRange) : SelectPeriodEvent + + object ResetToCurrentPeriod : SelectPeriodEvent + + object ThisYear : SelectPeriodEvent + + object LastYear : SelectPeriodEvent + + object ShiftForward : SelectPeriodEvent + + object ShiftBackward : SelectPeriodEvent +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/time/handling/SelectedPeriodViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/time/handling/SelectedPeriodViewModel.kt new file mode 100644 index 0000000..024c35b --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/time/handling/SelectedPeriodViewModel.kt @@ -0,0 +1,184 @@ +package com.ivy.core.ui.time.handling + +import android.annotation.SuppressLint +import android.content.Context +import androidx.compose.runtime.Immutable +import com.ivy.common.time.atEndOfDay +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.domain.FlowViewModel +import com.ivy.core.domain.action.period.SelectedPeriodFlow +import com.ivy.core.domain.action.period.SetSelectedPeriodAct +import com.ivy.core.domain.action.settings.startdayofmonth.StartDayOfMonthFlow +import com.ivy.core.domain.pure.time.* +import com.ivy.core.ui.action.mapping.MapSelectedPeriodUiAct +import com.ivy.core.ui.data.period.MonthUi +import com.ivy.core.ui.data.period.SelectedPeriodUi +import com.ivy.core.ui.data.period.TimeRangeUi +import com.ivy.core.ui.data.period.monthsList +import com.ivy.core.ui.time.handling.SelectedPeriodViewModel.State +import com.ivy.core.ui.time.handling.SelectedPeriodViewModel.UiState +import com.ivy.data.time.SelectedPeriod +import com.ivy.data.time.TimeRange +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import java.time.LocalDate +import javax.inject.Inject + +@SuppressLint("StaticFieldLeak") +@HiltViewModel +class SelectedPeriodViewModel @Inject constructor( + @ApplicationContext + private val appContext: Context, + private val startDayOfMonthFlow: StartDayOfMonthFlow, + private val selectedPeriodFlow: SelectedPeriodFlow, + private val setSelectedPeriodAct: SetSelectedPeriodAct, + private val timeProvider: TimeProvider, + private val mapSelectedPeriodUiAct: MapSelectedPeriodUiAct, +) : FlowViewModel() { + + // TODO: Refactor! Might be inefficient! + + data class State( + val selectedPeriod: SelectedPeriod, + val startDayOfMonth: Int, + // TODO: This creates 36 months items and consumes unnecessary memory + val months: List, + ) + + @Immutable + data class UiState( + val startDayOfMonth: Int, + // TODO: This creates 36 months items and consumes unnecessary memory + val months: List, + val selectedPeriodUi: SelectedPeriodUi, + ) + + override val initialState = State( + startDayOfMonth = 1, + months = emptyList(), + selectedPeriod = SelectedPeriod.AllTime(allTime()) + ) + + override val initialUi = UiState( + startDayOfMonth = 1, + months = emptyList(), + selectedPeriodUi = SelectedPeriodUi.AllTime( + periodBtnText = "", + rangeUi = TimeRangeUi( + range = allTime(), + fromText = "", + toText = "", + ) + ), + ) + + override val stateFlow: Flow = combine( + startDayOfMonthFlow(), selectedPeriodFlow() + ) { startDayOfMonth, selectedPeriod -> + val currentYear = timeProvider.dateNow().year + + State( + startDayOfMonth = startDayOfMonth, + months = monthsList(appContext, year = currentYear - 1, currentYear = false) + + monthsList(appContext, year = currentYear, currentYear = true) + + monthsList(appContext, year = currentYear + 1, currentYear = false), + selectedPeriod = selectedPeriod + ) + } + + override val uiFlow: Flow = stateFlow.map { + UiState( + startDayOfMonth = it.startDayOfMonth, + months = it.months, + selectedPeriodUi = mapSelectedPeriodUiAct(it.selectedPeriod) + ) + } + + override suspend fun handleEvent(event: SelectPeriodEvent) { + val selectedPeriod = when (event) { + SelectPeriodEvent.AllTime -> SelectedPeriod.AllTime(allTime()) + is SelectPeriodEvent.CustomRange -> SelectedPeriod.CustomRange(event.range) + is SelectPeriodEvent.InTheLast -> toSelectedPeriod(event) + is SelectPeriodEvent.Monthly -> monthlyPeriod( + // TODO: Refactor that + // 10 is a safe date in the middle of the month + dateInPeriod = LocalDate.of(event.month.year, event.month.number, 10), + startDayOfMonth = state.value.startDayOfMonth + ) + SelectPeriodEvent.ResetToCurrentPeriod -> currentMonthlyPeriod( + startDayOfMonth = state.value.startDayOfMonth, + timeProvider = timeProvider, + ) + SelectPeriodEvent.LastYear -> yearlyPeriod(timeProvider.dateNow().year - 1) + SelectPeriodEvent.ThisYear -> yearlyPeriod(timeProvider.dateNow().year) + is SelectPeriodEvent.ShiftForward -> shiftPeriodForward() + is SelectPeriodEvent.ShiftBackward -> shiftPeriodBackward() + } + + setSelectedPeriodAct(selectedPeriod) + } + + private fun toSelectedPeriod(event: SelectPeriodEvent.InTheLast): SelectedPeriod.InTheLast { + val now = timeProvider.timeNow() + val n = event.n + return SelectedPeriod.InTheLast( + n = n, + unit = event.unit, + range = TimeRange( + // n - 1 because we count today + // Negate: -n because we want to start from the **last** N unit + from = shiftTime(time = now, n = -(n - 1), unit = event.unit), + to = timeProvider.dateNow().atEndOfDay(), + ) + ) + } + + private fun shiftPeriodForward(): SelectedPeriod = + when (val selected = state.value.selectedPeriod) { + is SelectedPeriod.AllTime -> SelectedPeriod.AllTime(allTime()) + is SelectedPeriod.CustomRange -> shiftPeriod(selected.range, ShiftDirection.Forward) + is SelectedPeriod.InTheLast -> shiftPeriod(selected.range, ShiftDirection.Forward) + is SelectedPeriod.Monthly -> monthlyPeriod( + dateInPeriod = selected.range.from.toLocalDate() + .plusMonths(1), + startDayOfMonth = state.value.startDayOfMonth + ) + } + + private fun shiftPeriodBackward(): SelectedPeriod = + when (val selected = state.value.selectedPeriod) { + is SelectedPeriod.AllTime -> SelectedPeriod.AllTime(allTime()) + is SelectedPeriod.CustomRange -> shiftPeriod(selected.range, ShiftDirection.Backward) + is SelectedPeriod.InTheLast -> shiftPeriod(selected.range, ShiftDirection.Backward) + is SelectedPeriod.Monthly -> monthlyPeriod( + dateInPeriod = selected.range.from.toLocalDate() + .minusMonths(1), + startDayOfMonth = state.value.startDayOfMonth + ) + } + + private fun shiftPeriod( + range: TimeRange, + shiftDirection: ShiftDirection, + ): SelectedPeriod.CustomRange { + val lengthDays = periodLengthDays(range) + val shiftDays = when (shiftDirection) { + ShiftDirection.Forward -> lengthDays + 1 + ShiftDirection.Backward -> -lengthDays - 1 + }.toLong() + + return SelectedPeriod.CustomRange( + range = TimeRange( + from = range.from.plusDays(shiftDays), + to = range.to.plusDays(shiftDays), + ) + ) + } + + private enum class ShiftDirection { + Forward, Backward + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/time/picker/component/HorizontalWheelPicker.kt b/core/ui/src/main/java/com/ivy/core/ui/time/picker/component/HorizontalWheelPicker.kt new file mode 100644 index 0000000..5ae6814 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/time/picker/component/HorizontalWheelPicker.kt @@ -0,0 +1,131 @@ +package com.ivy.core.ui.time.picker.component + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.B1Second +import com.ivy.design.l1_buildingBlocks.SpacerHor + +private val ITEM_HEIGHT = 64.dp +private val ITEM_WIDTH = 104.dp +private val SELECTOR_LINES_PADDING_FROM_CENTER = 16.dp +private val SELECTOR_LINE_WIDTH = 2.dp + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun HorizontalWheelPicker( + items: List, + initialIndex: Int, + itemsCount: Int, + text: (T) -> String, + modifier: Modifier = Modifier, + onSelectedChange: (T) -> Unit, +) { + val listState = rememberLazyListState() + val selectedIndex by remember { + derivedStateOf { + (listState.firstVisibleItemIndex) + .coerceIn(0 until itemsCount) + } + } + LaunchedEffect(selectedIndex) { + onSelectedChange(items[selectedIndex.coerceIn(0 until itemsCount)]) + } + + LaunchedEffect(initialIndex) { + listState.scrollToItem(index = initialIndex) + } + + var selectIndexOnClick by remember { + // skip first spacer + // skip first item + // => select the 2nd (center item) + mutableStateOf(null) + } + LaunchedEffect(selectIndexOnClick) { + selectIndexOnClick?.let { + listState.animateScrollToItem(it) + // reset, so the same index can be re-selected + selectIndexOnClick = null + } + } + + val primary = UI.colors.primary + LazyRow( + modifier = modifier + .width(3 * ITEM_WIDTH) + .drawWithCache { + onDrawBehind { + val halfItem = ITEM_WIDTH.value / 2 + val padding = SELECTOR_LINES_PADDING_FROM_CENTER.toPx() + val lineWidth = SELECTOR_LINE_WIDTH.toPx() + drawLine( + start = Offset(x = center.x - halfItem - padding, y = 0f), + end = Offset(x = center.x - halfItem - padding, y = size.height), + strokeWidth = lineWidth, + color = primary, + cap = StrokeCap.Butt + ) + drawLine( + start = Offset(x = center.x + halfItem + padding, y = 0f), + end = Offset(x = center.x + halfItem + padding, y = size.height), + strokeWidth = lineWidth, + color = primary, + cap = StrokeCap.Butt + ) + } + }, + state = listState, + flingBehavior = rememberSnapFlingBehavior(lazyListState = listState) + ) { + item(key = "space_zero") { + SpacerHor(width = ITEM_WIDTH) + } + itemsIndexed( + items = items, + key = { index, _ -> index } + ) { index, item -> + Box( + modifier = Modifier + .defaultMinSize(minHeight = ITEM_HEIGHT) + .width(ITEM_WIDTH) + .clip(UI.shapes.squared) + .clickable { + selectIndexOnClick = index + }, + contentAlignment = Alignment.Center + ) { + val selected = index == selectedIndex + B1Second( + text = text(item), + fontWeight = FontWeight.Bold, + color = if (selected) + UI.colors.primary else UI.colorsInverted.pure, + textAlign = TextAlign.Center, + maxLines = 1 + ) + } + } + item(key = "space_last") { + SpacerHor(width = ITEM_WIDTH) + } + } +} diff --git a/core/ui/src/main/java/com/ivy/core/ui/time/picker/component/VerticalWheelPicker.kt b/core/ui/src/main/java/com/ivy/core/ui/time/picker/component/VerticalWheelPicker.kt new file mode 100644 index 0000000..eccd625 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/time/picker/component/VerticalWheelPicker.kt @@ -0,0 +1,131 @@ +package com.ivy.core.ui.time.picker.component + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.B1Second +import com.ivy.design.l1_buildingBlocks.SpacerVer + +private val ITEM_HEIGHT = 72.dp +private val ITEM_WIDTH = 104.dp +private val SELECTOR_LINES_PADDING_FROM_CENTER = 16.dp +private val SELECTOR_LINE_WIDTH = 2.dp + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun VerticalWheelPicker( + items: List, + initialIndex: Int, + itemsCount: Int, + text: (T) -> String, + modifier: Modifier = Modifier, + onSelectedChange: (T) -> Unit, +) { + val listState = rememberLazyListState() + val selectedIndex by remember { + derivedStateOf { + (listState.firstVisibleItemIndex) + .coerceIn(0 until itemsCount) + } + } + LaunchedEffect(selectedIndex) { + onSelectedChange(items[selectedIndex.coerceIn(0 until itemsCount)]) + } + + LaunchedEffect(initialIndex) { + listState.scrollToItem(index = initialIndex) + } + + var selectIndexOnClick by remember { + // skip first spacer + // skip first item + // => select the 2nd (center item) + mutableStateOf(null) + } + LaunchedEffect(selectIndexOnClick) { + selectIndexOnClick?.let { + listState.animateScrollToItem(it) + // reset, so the same index can be re-selected + selectIndexOnClick = null + } + } + + val primary = UI.colors.primary + LazyColumn( + modifier = modifier + .height(3 * ITEM_HEIGHT) + .drawWithCache { + onDrawBehind { + val halfItem = ITEM_HEIGHT.value / 2 + val padding = SELECTOR_LINES_PADDING_FROM_CENTER.toPx() + val lineWidth = SELECTOR_LINE_WIDTH.toPx() + drawLine( + start = Offset(x = 0f, y = center.y - halfItem - padding), + end = Offset(x = size.width, y = center.y - halfItem - padding), + strokeWidth = lineWidth, + color = primary, + cap = StrokeCap.Butt + ) + drawLine( + start = Offset(x = 0f, y = center.y + halfItem + padding), + end = Offset(x = size.width, y = center.y + halfItem + padding), + strokeWidth = lineWidth, + color = primary, + cap = StrokeCap.Butt + ) + } + }, + state = listState, + flingBehavior = rememberSnapFlingBehavior(lazyListState = listState) + ) { + item(key = "space_zero") { + SpacerVer(height = ITEM_HEIGHT) + } + itemsIndexed( + items = items, + key = { index, _ -> index } + ) { index, item -> + Box( + modifier = Modifier + .defaultMinSize(minWidth = ITEM_WIDTH) + .height(ITEM_HEIGHT) + .clip(UI.shapes.squared) + .clickable { + selectIndexOnClick = index + }, + contentAlignment = Alignment.Center + ) { + val selected = index == selectedIndex + B1Second( + text = text(item), + fontWeight = FontWeight.Bold, + color = if (selected) + UI.colors.primary else UI.colorsInverted.pure, + textAlign = TextAlign.Center, + maxLines = 1 + ) + } + } + item(key = "space_last") { + SpacerVer(height = ITEM_HEIGHT) + } + } +} diff --git a/core/ui/src/main/java/com/ivy/core/ui/time/picker/date/DatePickerEvent.kt b/core/ui/src/main/java/com/ivy/core/ui/time/picker/date/DatePickerEvent.kt new file mode 100644 index 0000000..dc4151a --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/time/picker/date/DatePickerEvent.kt @@ -0,0 +1,13 @@ +package com.ivy.core.ui.time.picker.date + +import com.ivy.core.ui.time.picker.date.data.PickerDay +import com.ivy.core.ui.time.picker.date.data.PickerMonth +import com.ivy.core.ui.time.picker.date.data.PickerYear +import java.time.LocalDate + +sealed interface DatePickerEvent { + data class Initial(val selected: LocalDate) : DatePickerEvent + data class DayChange(val day: PickerDay) : DatePickerEvent + data class MonthChange(val month: PickerMonth) : DatePickerEvent + data class YearChange(val year: PickerYear) : DatePickerEvent +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/time/picker/date/DatePickerModal.kt b/core/ui/src/main/java/com/ivy/core/ui/time/picker/date/DatePickerModal.kt new file mode 100644 index 0000000..d8e33a3 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/time/picker/date/DatePickerModal.kt @@ -0,0 +1,189 @@ +package com.ivy.core.ui.time.picker.date + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.time.picker.component.HorizontalWheelPicker +import com.ivy.core.ui.time.picker.component.VerticalWheelPicker +import com.ivy.core.ui.time.picker.date.data.PickerDay +import com.ivy.core.ui.time.picker.date.data.PickerMonth +import com.ivy.core.ui.time.picker.date.data.PickerYear +import com.ivy.core.ui.uiStatePreviewSafe +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.B2Second +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l1_buildingBlocks.SpacerWeight +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Positive +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.previewModal +import com.ivy.design.l2_components.modal.scope.ModalScope +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.hiltViewModelPreviewSafe +import java.time.LocalDate + +@Composable +fun BoxScope.DatePickerModal( + modal: IvyModal, + selected: LocalDate, + level: Int = 1, + contentTop: @Composable ModalScope.() -> Unit = {}, + onPick: (LocalDate) -> Unit, +) { + val viewModel: DatePickerViewModel? = hiltViewModelPreviewSafe() + val state = uiStatePreviewSafe(viewModel = viewModel, preview = ::previewState) + + LaunchedEffect(selected) { + viewModel?.onEvent(DatePickerEvent.Initial(selected)) + } + + Modal( + modal = modal, + level = level, + actions = { + Positive(text = "Choose") { + onPick(state.selected) + modal.hide() + } + } + ) { + Title(text = "Pick a date") + contentTop() + SpacerVer(height = 24.dp) + B2Second( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = state.selectedContext, + color = UI.colors.primary, + fontWeight = FontWeight.Bold, + ) + SpacerVer(height = 24.dp) + YearWheel( + modifier = Modifier.align(Alignment.CenterHorizontally), + years = state.years, + yearsCount = state.yearsListSize, + initialYearValue = selected.year, + onYearChange = { viewModel?.onEvent(DatePickerEvent.YearChange(it)) } + ) + SpacerVer(height = 16.dp) + Row { + SpacerWeight(weight = 1f) + DayWheel( + days = state.days, + daysCount = state.daysListSize, + initialDayValue = selected.dayOfMonth - 1, + onDayChange = { viewModel?.onEvent(DatePickerEvent.DayChange(it)) } + ) + MonthWheel( + months = state.months, + monthsCount = state.monthsListSize, + initialMonthValue = selected.monthValue - 1, + onMonthChange = { viewModel?.onEvent(DatePickerEvent.MonthChange(it)) } + ) + SpacerWeight(weight = 1f) + } + SpacerVer(height = 24.dp) + } +} + +@Composable +private fun DayWheel( + days: List, + daysCount: Int, + initialDayValue: Int, + modifier: Modifier = Modifier, + onDayChange: (PickerDay) -> Unit, +) { + VerticalWheelPicker( + modifier = modifier, + items = days, + itemsCount = daysCount, + initialIndex = initialDayValue, + text = { it.text }, + onSelectedChange = onDayChange + ) +} + +@Composable +private fun MonthWheel( + months: List, + monthsCount: Int, + initialMonthValue: Int, + modifier: Modifier = Modifier, + onMonthChange: (PickerMonth) -> Unit, +) { + VerticalWheelPicker( + modifier = modifier, + items = months, + itemsCount = monthsCount, + initialIndex = initialMonthValue, + text = { it.text }, + onSelectedChange = onMonthChange + ) +} + +@Composable +private fun YearWheel( + years: List, + yearsCount: Int, + initialYearValue: Int, + modifier: Modifier = Modifier, + onYearChange: (PickerYear) -> Unit, +) { + HorizontalWheelPicker( + modifier = modifier, + items = years, + itemsCount = yearsCount, + initialIndex = initialYearValue - (years.firstOrNull()?.value ?: 0), + text = { it.text }, + onSelectedChange = onYearChange + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = previewModal() + DatePickerModal( + modal = modal, + selected = LocalDate.now(), + onPick = {}, + ) + } +} + +private fun previewState() = DatePickerState( + days = listOf( + PickerDay("1", 1), + PickerDay("2", 2), + PickerDay("3", 3), + PickerDay("4", 4), + ), + daysListSize = 3, + months = listOf( + PickerMonth("Jan", 1), + PickerMonth("Feb", 2), + PickerMonth("Mar", 3), + PickerMonth("Apr", 4), + ), + monthsListSize = 3, + years = listOf( + PickerYear("2020", 2020), + PickerYear("2021", 2021), + PickerYear("2022", 2022), + PickerYear("2023", 2023), + ), + yearsListSize = 3, + selectedContext = "Today", + selected = LocalDate.now(), +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/time/picker/date/DatePickerState.kt b/core/ui/src/main/java/com/ivy/core/ui/time/picker/date/DatePickerState.kt new file mode 100644 index 0000000..dd55e29 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/time/picker/date/DatePickerState.kt @@ -0,0 +1,20 @@ +package com.ivy.core.ui.time.picker.date + +import androidx.compose.runtime.Immutable +import com.ivy.core.ui.time.picker.date.data.PickerDay +import com.ivy.core.ui.time.picker.date.data.PickerMonth +import com.ivy.core.ui.time.picker.date.data.PickerYear +import java.time.LocalDate + +@Immutable +data class DatePickerState( + val days: List, + val daysListSize: Int, + val months: List, + val monthsListSize: Int, + val years: List, + val yearsListSize: Int, + + val selectedContext: String, + val selected: LocalDate, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/time/picker/date/DatePickerViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/time/picker/date/DatePickerViewModel.kt new file mode 100644 index 0000000..526afb9 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/time/picker/date/DatePickerViewModel.kt @@ -0,0 +1,106 @@ +package com.ivy.core.ui.time.picker.date + +import android.annotation.SuppressLint +import android.content.Context +import com.ivy.common.time.contextText +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.withDayOfMonthSafe +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.ui.time.picker.date.data.PickerDay +import com.ivy.core.ui.time.picker.date.data.PickerMonth +import com.ivy.core.ui.time.picker.date.data.PickerYear +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@SuppressLint("StaticFieldLeak") +@HiltViewModel +class DatePickerViewModel @Inject constructor( + @ApplicationContext private val appContext: Context, + timeProvider: TimeProvider +) : SimpleFlowViewModel() { + companion object { + const val YEARS_FROM_NOW_SUPPORT = 100 + } + + override val initialUi = DatePickerState( + days = emptyList(), + daysListSize = 0, + months = emptyList(), + monthsListSize = 0, + years = emptyList(), + yearsListSize = 0, + selectedContext = "Today", + selected = timeProvider.dateNow() + ) + + private val selectedDate = MutableStateFlow(initialUi.selected) + + override val uiFlow: Flow = selectedDate.map { selected -> + val days = (1..selected.month.maxLength()).map { PickerDay(it.toString(), it) } + val months = listOf( + PickerMonth("Jan", 1), + PickerMonth("Feb", 2), + PickerMonth("Mar", 3), + PickerMonth("Apr", 4), + PickerMonth("May", 5), + PickerMonth("Jun", 6), + PickerMonth("Jul", 7), + PickerMonth("Aug", 8), + PickerMonth("Sep", 9), + PickerMonth("Oct", 10), + PickerMonth("Nov", 11), + PickerMonth("Dec", 12), + ) + + val currentYear = timeProvider.dateNow().year + val years = (currentYear - YEARS_FROM_NOW_SUPPORT.. + currentYear + YEARS_FROM_NOW_SUPPORT) + .map { + PickerYear(it.toString(), it) + } + + DatePickerState( + days = days, + daysListSize = days.size, + months = months, + monthsListSize = months.size, + years = years, + yearsListSize = years.size, + selectedContext = selected.contextText( + alwaysShowWeekday = true, + getString = appContext::getString + ), + selected = selected, + ) + } + + + // region Event Handling + override suspend fun handleEvent(event: DatePickerEvent) = when (event) { + is DatePickerEvent.Initial -> handleInitial(event) + is DatePickerEvent.DayChange -> handleDayChange(event) + is DatePickerEvent.MonthChange -> handleMonthChange(event) + is DatePickerEvent.YearChange -> handleYearChange(event) + } + + private fun handleInitial(event: DatePickerEvent.Initial) { + selectedDate.value = event.selected + } + + private fun handleDayChange(event: DatePickerEvent.DayChange) { + selectedDate.value = selectedDate.value.withDayOfMonthSafe(event.day.value) + } + + private fun handleMonthChange(event: DatePickerEvent.MonthChange) { + selectedDate.value = selectedDate.value.withMonth(event.month.value) + } + + private fun handleYearChange(event: DatePickerEvent.YearChange) { + selectedDate.value = selectedDate.value.withYear(event.year.value) + } + // endregion +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/time/picker/date/data/PickerDay.kt b/core/ui/src/main/java/com/ivy/core/ui/time/picker/date/data/PickerDay.kt new file mode 100644 index 0000000..f3dbba5 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/time/picker/date/data/PickerDay.kt @@ -0,0 +1,9 @@ +package com.ivy.core.ui.time.picker.date.data + +import androidx.compose.runtime.Immutable + +@Immutable +data class PickerDay( + val text: String, + val value: Int, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/time/picker/date/data/PickerMonth.kt b/core/ui/src/main/java/com/ivy/core/ui/time/picker/date/data/PickerMonth.kt new file mode 100644 index 0000000..c3681d5 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/time/picker/date/data/PickerMonth.kt @@ -0,0 +1,9 @@ +package com.ivy.core.ui.time.picker.date.data + +import androidx.compose.runtime.Immutable + +@Immutable +data class PickerMonth( + val text: String, + val value: Int, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/time/picker/date/data/PickerYear.kt b/core/ui/src/main/java/com/ivy/core/ui/time/picker/date/data/PickerYear.kt new file mode 100644 index 0000000..2c164b7 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/time/picker/date/data/PickerYear.kt @@ -0,0 +1,9 @@ +package com.ivy.core.ui.time.picker.date.data + +import androidx.compose.runtime.Immutable + +@Immutable +data class PickerYear( + val text: String, + val value: Int, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/time/picker/time/TimePickerEvent.kt b/core/ui/src/main/java/com/ivy/core/ui/time/picker/time/TimePickerEvent.kt new file mode 100644 index 0000000..15fb335 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/time/picker/time/TimePickerEvent.kt @@ -0,0 +1,12 @@ +package com.ivy.core.ui.time.picker.time + +import com.ivy.core.ui.time.picker.time.data.PickerHour +import com.ivy.core.ui.time.picker.time.data.PickerMinute +import java.time.LocalTime + +sealed interface TimePickerEvent { + data class Initial(val initialTime: LocalTime) : TimePickerEvent + data class HourChange(val pickerHour: PickerHour) : TimePickerEvent + data class MinuteChange(val minute: PickerMinute) : TimePickerEvent + data class AmPmChange(val amPm: AmPm) : TimePickerEvent +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/time/picker/time/TimePickerModal.kt b/core/ui/src/main/java/com/ivy/core/ui/time/picker/time/TimePickerModal.kt new file mode 100644 index 0000000..78615a7 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/time/picker/time/TimePickerModal.kt @@ -0,0 +1,185 @@ +package com.ivy.core.ui.time.picker.time + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.time.picker.component.HorizontalWheelPicker +import com.ivy.core.ui.time.picker.component.VerticalWheelPicker +import com.ivy.core.ui.time.picker.time.data.PickerHour +import com.ivy.core.ui.time.picker.time.data.PickerMinute +import com.ivy.core.ui.uiStatePreviewSafe +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.H2Second +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l1_buildingBlocks.SpacerWeight +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Positive +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.previewModal +import com.ivy.design.l2_components.modal.scope.ModalScope +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.hiltViewModelPreviewSafe +import java.time.LocalTime + +@Composable +fun BoxScope.TimePickerModal( + modal: IvyModal, + selected: LocalTime, + level: Int = 1, + contentTop: @Composable ModalScope.() -> Unit = {}, + onPick: (LocalTime) -> Unit, +) { + val viewModel: TimePickerViewModel? = hiltViewModelPreviewSafe() + val state = uiStatePreviewSafe(viewModel = viewModel, preview = ::previewState) + + LaunchedEffect(selected) { + viewModel?.onEvent(TimePickerEvent.Initial(selected)) + } + + Modal( + modal = modal, + level = level, + actions = { + Positive(text = "Choose") { + onPick(state.selected) + modal.hide() + } + } + ) { + Title(text = "Pick a time") + contentTop() + SpacerVer(height = 24.dp) + if (state.amPm != null) { + AmPmWheel( + modifier = Modifier.align(Alignment.CenterHorizontally), + initialAmPmValue = state.amPm, + onAmPmChange = { + viewModel?.onEvent(TimePickerEvent.AmPmChange(it)) + } + ) + SpacerVer(height = 16.dp) + } + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + SpacerWeight(weight = 1f) + HoursWheel( + hours = state.hours, + hoursCount = state.hoursListSize, + initialHourIndex = state.selectedHourIndex, + onHourChange = { viewModel?.onEvent(TimePickerEvent.HourChange(it)) } + ) + SpacerHor(width = 12.dp) + H2Second( + text = ":", + fontWeight = FontWeight.Bold, + color = UI.colors.primary, + ) + SpacerHor(width = 12.dp) + MinuteWheel( + minutes = state.minutes, + minutesCount = state.minutesListSize, + initialMinute = selected.minute, + onMinuteChange = { viewModel?.onEvent(TimePickerEvent.MinuteChange(it)) } + ) + SpacerWeight(weight = 1f) + } + SpacerVer(height = 24.dp) + } +} + +@Composable +private fun HoursWheel( + hours: List, + hoursCount: Int, + initialHourIndex: Int, + modifier: Modifier = Modifier, + onHourChange: (PickerHour) -> Unit, +) { + VerticalWheelPicker( + modifier = modifier, + items = hours, + itemsCount = hoursCount, + initialIndex = initialHourIndex, + text = { it.text }, + onSelectedChange = onHourChange + ) +} + +@Composable +private fun MinuteWheel( + minutes: List, + minutesCount: Int, + initialMinute: Int, + modifier: Modifier = Modifier, + onMinuteChange: (PickerMinute) -> Unit, +) { + VerticalWheelPicker( + modifier = modifier, + items = minutes, + itemsCount = minutesCount, + initialIndex = initialMinute, + text = { it.text }, + onSelectedChange = onMinuteChange + ) +} + +@Composable +private fun AmPmWheel( + initialAmPmValue: AmPm, + modifier: Modifier = Modifier, + onAmPmChange: (AmPm) -> Unit, +) { + HorizontalWheelPicker( + modifier = modifier, + items = listOf( + AmPm.AM to "AM", + AmPm.PM to "PM", + AmPm.AM to "AM", + AmPm.PM to "PM", + ), + itemsCount = 4, + initialIndex = when (initialAmPmValue) { + AmPm.AM -> 0 + AmPm.PM -> 1 + }, + text = { it.second }, + onSelectedChange = { (amPm, _) -> + onAmPmChange(amPm) + } + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = previewModal() + TimePickerModal( + modal = modal, + selected = LocalTime.now(), + onPick = {}, + ) + } +} + +private fun previewState() = TimePickerState( + amPm = AmPm.PM, + hours = (1..11).map { PickerHour(it.toString().padStart(2, '0'), it) }, + hoursListSize = 1, + minutes = (0..59).map { PickerMinute(it.toString().padStart(2, '0'), it) }, + minutesListSize = 60, + selected = LocalTime.now(), + selectedHourIndex = LocalTime.now().hour, +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/time/picker/time/TimePickerState.kt b/core/ui/src/main/java/com/ivy/core/ui/time/picker/time/TimePickerState.kt new file mode 100644 index 0000000..bb1f639 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/time/picker/time/TimePickerState.kt @@ -0,0 +1,24 @@ +package com.ivy.core.ui.time.picker.time + +import androidx.compose.runtime.Immutable +import com.ivy.core.ui.time.picker.time.data.PickerHour +import com.ivy.core.ui.time.picker.time.data.PickerMinute +import java.time.LocalTime + +@Immutable +data class TimePickerState( + val amPm: AmPm?, + val hours: List, + val hoursListSize: Int, + val minutes: List, + val minutesListSize: Int, + + val selectedHourIndex: Int, + val selected: LocalTime, +) + +@Immutable +enum class AmPm { + AM, + PM, +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/time/picker/time/TimePickerViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/time/picker/time/TimePickerViewModel.kt new file mode 100644 index 0000000..b9c9d58 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/time/picker/time/TimePickerViewModel.kt @@ -0,0 +1,154 @@ +package com.ivy.core.ui.time.picker.time + +import android.annotation.SuppressLint +import android.content.Context +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.time.uses24HourFormat +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.ui.time.picker.time.data.PickerHour +import com.ivy.core.ui.time.picker.time.data.PickerMinute +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +/** + * https://en.wikipedia.org/wiki/12-hour_clock + */ +@SuppressLint("StaticFieldLeak") +@HiltViewModel +class TimePickerViewModel @Inject constructor( + @ApplicationContext private val appContext: Context, + timeProvider: TimeProvider +) : SimpleFlowViewModel() { + // TODO: AM/PM <-> 24h logic is too complex and messy! Consider refactoring! + // TODO: Very shitty code!!! + + override val initialUi = TimePickerState( + amPm = null, + hours = emptyList(), + hoursListSize = 0, + minutes = emptyList(), + minutesListSize = 0, + selected = timeProvider.timeNow().toLocalTime(), + selectedHourIndex = 0, + ) + + private val amPm = MutableStateFlow(initialUi.amPm) + private val selected = MutableStateFlow(initialUi.selected) + private val initialSelected = MutableStateFlow(initialUi.selected) + + override val uiFlow: Flow = combine( + selected, + initialSelected, + amPm + ) { selected24, initialSelected, amPm -> + val hours = when (amPm) { + AmPm.AM -> 1..12 + AmPm.PM -> 1..11 + null -> 0..23 + }.map { + PickerHour( + value = it, + text = it.toString().padStart(2, '0'), + ) + } + val minutes = (0..59).map { + PickerMinute( + value = it, + text = it.toString().padStart(2, '0'), + ) + } + + TimePickerState( + amPm = amPm, + hours = hours, + hoursListSize = hours.size, + minutes = minutes, + minutesListSize = minutes.size, + selected = selected24, + selectedHourIndex = when (amPm) { + AmPm.AM -> { + /* + Possible values: 1..12 + Indexes: 0..11 + */ + (if (initialSelected.hour == 0) 12 else initialSelected.hour) - 1 + // -1 because indexes start from 0 and AM/PM doesn't! + } + AmPm.PM -> { + /* + Possible values: 1..11 + Indexes: 0..10 + */ + (initialSelected.hour % 12) - 1 // -1 because indexes start from 0 and AM/PM doesn't! + } + null -> { + /* + Possible values: 0..23 + Indexes: 0..23 + */ + initialSelected.hour + } + }.coerceIn(0..hours.lastIndex), + ) + } + + + // region Event Handling + override suspend fun handleEvent(event: TimePickerEvent) = when (event) { + is TimePickerEvent.Initial -> handleInitial(event) + is TimePickerEvent.HourChange -> handleHourChange(event) + is TimePickerEvent.AmPmChange -> handleAmPmChange(event) + is TimePickerEvent.MinuteChange -> handleMinuteChange(event) + } + + private fun handleInitial(event: TimePickerEvent.Initial) { + updateAmPm(event.initialTime.hour) + initialSelected.value = event.initialTime + selected.value = event.initialTime + } + + private fun handleHourChange(event: TimePickerEvent.HourChange) { + val pickedHour = event.pickerHour.value + + // transform the input picker hour to 24h format (0-23) + val newHour24 = when (amPm.value) { + AmPm.AM -> if (pickedHour == 12) 0 else pickedHour + AmPm.PM -> pickedHour + 12 + null -> pickedHour + } + + updateHour(newHour24) + } + + private fun updateAmPm(newHour24: Int): AmPm? { + val newAmPm = if (!uses24HourFormat(appContext)) { + if (newHour24 < 12) AmPm.AM else AmPm.PM + } else null + amPm.value = newAmPm + return newAmPm + } + + private fun handleAmPmChange(event: TimePickerEvent.AmPmChange) { + amPm.value = event.amPm + val newHour24 = when (event.amPm) { + AmPm.AM -> if (selected.value.hour == 0) 12 else selected.value.hour + AmPm.PM -> selected.value.hour + 12 + } + updateHour(newHour24) + initialSelected.value = selected.value + } + + private fun updateHour(newHour24: Int) { + selected.value = selected.value.withHour(newHour24.coerceIn(0..23)) + } + + private fun handleMinuteChange(event: TimePickerEvent.MinuteChange) { + selected.value = selected.value.withMinute(event.minute.value) + } + + // endregion +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/time/picker/time/data/PickerHour.kt b/core/ui/src/main/java/com/ivy/core/ui/time/picker/time/data/PickerHour.kt new file mode 100644 index 0000000..85aea5e --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/time/picker/time/data/PickerHour.kt @@ -0,0 +1,9 @@ +package com.ivy.core.ui.time.picker.time.data + +import androidx.compose.runtime.Immutable + +@Immutable +data class PickerHour( + val text: String, + val value: Int, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/time/picker/time/data/PickerMinute.kt b/core/ui/src/main/java/com/ivy/core/ui/time/picker/time/data/PickerMinute.kt new file mode 100644 index 0000000..8377ebc --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/time/picker/time/data/PickerMinute.kt @@ -0,0 +1,9 @@ +package com.ivy.core.ui.time.picker.time.data + +import androidx.compose.runtime.Immutable + +@Immutable +data class PickerMinute( + val text: String, + val value: Int, +) \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/transaction/TransactionTypeExt.kt b/core/ui/src/main/java/com/ivy/core/ui/transaction/TransactionTypeExt.kt new file mode 100644 index 0000000..ad2b283 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/transaction/TransactionTypeExt.kt @@ -0,0 +1,26 @@ +package com.ivy.core.ui.transaction + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.ivy.data.transaction.TransactionType +import com.ivy.design.l0_system.color.Green +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.resources.R + +@Composable +fun TransactionType.humanText(): String = when (this) { + TransactionType.Income -> stringResource(R.string.income) + TransactionType.Expense -> stringResource(R.string.expense) +} + +@DrawableRes +fun TransactionType.icon(): Int = when (this) { + TransactionType.Income -> R.drawable.ic_income + TransactionType.Expense -> R.drawable.ic_expense +} + +fun TransactionType.feeling(): Feeling = when (this) { + TransactionType.Income -> Feeling.Custom(Green) + TransactionType.Expense -> Feeling.Negative +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/transaction/TransactionsLazyColumn.kt b/core/ui/src/main/java/com/ivy/core/ui/transaction/TransactionsLazyColumn.kt new file mode 100644 index 0000000..b0d4391 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/transaction/TransactionsLazyColumn.kt @@ -0,0 +1,134 @@ +package com.ivy.core.ui.transaction + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.algorithm.trnhistory.data.TrnListItemUi +import com.ivy.core.ui.transaction.handling.DueActionsHandler +import com.ivy.core.ui.transaction.handling.TrnItemClickHandler +import com.ivy.core.ui.transaction.handling.defaultDueActionsHandler +import com.ivy.core.ui.transaction.handling.defaultTrnItemClickHandler +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.util.IvyPreview + +private var lazyStateCache: MutableMap = mutableMapOf() + +@Stable +data class TransactionsListState internal constructor( + val scrollStateKey: String?, + val listState: LazyListState, +) + +/** + * @param scrollStateKey an **unique key** by which the `LazyListState` is cached + * so scroll progress is persisted. Set to **null** if you don't want to + * persist scroll state. + */ +@Composable +fun rememberTransactionsListState(scrollStateKey: String?) = TransactionsListState( + scrollStateKey = scrollStateKey, + listState = rememberLazyListState( + initialFirstVisibleItemIndex = + lazyStateCache[scrollStateKey]?.firstVisibleItemIndex ?: 0, + initialFirstVisibleItemScrollOffset = + lazyStateCache[scrollStateKey]?.firstVisibleItemScrollOffset ?: 0 + ) +) + + +/** + * Displays a list of transactions _(Upcoming, Overdue & History)_ efficiently in a **LazyColumn**. + * Optionally, **persists scroll progress** so when the user navigates back to the screen + * the list state is the same as the user left it. + * + * @param modifier a Modifier for the LazyColumn. + * @param state use [rememberTransactionsListState] + * @param dueActionsHandler _(optional)_ skip or pay/get planned payment callbacks, + * if null "Skip" and "Pay"/"Get" buttons won't be shown. + * @param contentAboveTrns _(optional)_ LazyColumn items above the transactions list. + * @param contentBelowTrns _(optional)_ LazyColum items below the transactions list. + * Defaults to 300.dp spacer so the transactions list can be scrollable. + * @param emptyState _(optional)_ empty state title and message. + * @param trnItemClickHandler _(optional)_ custom handling for + * transaction click, category click and account click events for a transaction item in the list. + */ +@Composable +fun TransactionsLazyColumn( + items: List, + state: TransactionsListState, + modifier: Modifier = Modifier, + contentAboveTrns: (LazyListScope.(LazyListState) -> Unit)? = null, + contentBelowTrns: (LazyListScope.(LazyListState) -> Unit)? = { scrollingSpace() }, + emptyState: EmptyState = defaultEmptyState(), + dueActionsHandler: DueActionsHandler = defaultDueActionsHandler(), + trnItemClickHandler: TrnItemClickHandler = defaultTrnItemClickHandler(), + onFirstVisibleItemChange: (suspend (Int) -> Unit)? = null, +) { + if (onFirstVisibleItemChange != null) { + val firstVisibleItemIndex by remember { + derivedStateOf { state.listState.firstVisibleItemIndex } + } + + LaunchedEffect(firstVisibleItemIndex) { + onFirstVisibleItemChange(firstVisibleItemIndex) + } + } + + if (state.scrollStateKey != null) { + // Cache scrolling state + DisposableEffect(state.scrollStateKey) { + lazyStateCache[state.scrollStateKey] = state.listState + onDispose {} + } + } + + LazyColumn( + modifier = modifier, + state = state.listState + ) { + contentAboveTrns?.invoke(this, state.listState) + transactionsList( + items = items, + emptyState = emptyState, + dueActionsHandler = dueActionsHandler, + trnClickHandler = trnItemClickHandler, + ) + contentBelowTrns?.invoke(this, state.listState) + } +} + +private fun LazyListScope.scrollingSpace() { + item { + SpacerVer(height = 300.dp) + } +} + + +// region Previews +@Preview +@Composable +private fun Preview_Full() { + IvyPreview { + TransactionsLazyColumn( + items = sampleTrnListItems(), + state = rememberTransactionsListState(scrollStateKey = "preview1") + ) + } +} + +@Preview +@Composable +private fun Preview_EmptyState() { + IvyPreview { + TransactionsLazyColumn( + items = emptyList(), + state = rememberTransactionsListState(scrollStateKey = "preview2") + ) + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/transaction/TransactionsList.kt b/core/ui/src/main/java/com/ivy/core/ui/transaction/TransactionsList.kt new file mode 100644 index 0000000..d3098cd --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/transaction/TransactionsList.kt @@ -0,0 +1,335 @@ +package com.ivy.core.ui.transaction + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.common.time.timeNow +import com.ivy.core.domain.algorithm.trnhistory.OverdueSectionKey +import com.ivy.core.domain.algorithm.trnhistory.UpcomingSectionKey +import com.ivy.core.domain.algorithm.trnhistory.toggleCollapseExpandTrnListKey +import com.ivy.core.domain.pure.format.SignedValueUi +import com.ivy.core.domain.pure.format.dummyValueUi +import com.ivy.core.ui.algorithm.trnhistory.data.* +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.core.ui.data.dummyCategoryUi +import com.ivy.core.ui.data.icon.dummyIconSized +import com.ivy.core.ui.data.icon.dummyIconUnknown +import com.ivy.core.ui.data.transaction.dummyTrnTimeActualUi +import com.ivy.core.ui.data.transaction.dummyTrnTimeDueUi +import com.ivy.core.ui.transaction.handling.DueActionsHandler +import com.ivy.core.ui.transaction.handling.TrnItemClickHandler +import com.ivy.core.ui.transaction.handling.defaultDueActionsHandler +import com.ivy.core.ui.transaction.handling.defaultTrnItemClickHandler +import com.ivy.core.ui.transaction.item.DateDivider +import com.ivy.core.ui.transaction.item.DueSectionDivider +import com.ivy.core.ui.transaction.item.TransactionCard +import com.ivy.core.ui.transaction.item.TransferCard +import com.ivy.data.transaction.TransactionType +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.* +import com.ivy.design.l1_buildingBlocks.B1 +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l1_buildingBlocks.IconRes +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.util.IvyPreview +import com.ivy.resources.R + +// region EmptyState data +@Immutable +data class EmptyState( + val title: String, + val description: String, +) + +@Composable +fun defaultEmptyState() = EmptyState( + title = stringResource(R.string.no_transactions), + description = stringResource(R.string.no_transactions_desc) +) +// endregion + +internal fun LazyListScope.transactionsList( + items: List, + emptyState: EmptyState, + trnClickHandler: TrnItemClickHandler, + dueActionsHandler: DueActionsHandler, +) { + trnListItems( + items = items, + trnClickHandler = trnClickHandler, + dueActionsHandler = dueActionsHandler, + ) + + val isEmpty by derivedStateOf { items.isEmpty() } + if (isEmpty) { + emptyState(emptyState) + } +} + +private fun LazyListScope.trnListItems( + items: List, + trnClickHandler: TrnItemClickHandler, + dueActionsHandler: DueActionsHandler, +) { + items( + items = items, + key = { item -> + when (item) { + is DateDividerUi -> item.id + is DueDividerUi -> item.id + is TransactionUi -> item.id + is TransferUi -> item.batchId + } + } + ) { item -> + when (item) { + is DateDividerUi -> { + SpacerVer(height = 16.dp) + DateDivider(item) + } + is DueDividerUi -> { + SpacerVer(height = 16.dp) + DueSectionDivider( + divider = item, + setExpanded = { + toggleCollapseExpandTrnListKey( + when (item.type) { + DueDividerUiType.Upcoming -> UpcomingSectionKey + DueDividerUiType.Overdue -> OverdueSectionKey + } + ) + } + ) + } + is TransactionUi -> { + SpacerVer(height = 12.dp) + TransactionCard( + modifier = Modifier.padding(horizontal = 16.dp), + trn = item, + onClick = trnClickHandler.onTrnClick, + onAccountClick = trnClickHandler.onAccountClick, + onCategoryClick = trnClickHandler.onCategoryClick, + onExecute = dueActionsHandler.onExecuteTrn, + onSkip = dueActionsHandler.onSkipTrn + ) + } + is TransferUi -> { + SpacerVer(height = 12.dp) + TransferCard( + modifier = Modifier.padding(horizontal = 16.dp), + transfer = item, + onClick = trnClickHandler.onTransferClick, + onAccountClick = trnClickHandler.onAccountClick, + onCategoryClick = trnClickHandler.onCategoryClick, + onExecuteTransfer = dueActionsHandler.onExecuteTransfer, + onSkipTransfer = dueActionsHandler.onSkipTransfer, + ) + } + } + } +} + +private fun LazyListScope.emptyState(emptyState: EmptyState) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + .padding(vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + IconRes(icon = R.drawable.ic_notransactions, tint = UI.colors.neutral) + SpacerVer(height = 24.dp) + B1( + text = emptyState.title, + modifier = Modifier.fillMaxWidth(), + color = UI.colors.neutral, + fontWeight = FontWeight.ExtraBold, + textAlign = TextAlign.Center, + ) + SpacerVer(height = 8.dp) + B2( + text = emptyState.description, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + color = UI.colors.neutral, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center + ) + } + } +} + +// region Previews +@Preview +@Composable +private fun Preview_Full() { + IvyPreview { + val emptyState = defaultEmptyState() + val items = sampleTrnListItems() + val trnItemClickHandler = defaultTrnItemClickHandler() + val dueActionsHandler = defaultDueActionsHandler() + + + LazyColumn { + transactionsList( + items = items, + emptyState = emptyState, + trnClickHandler = trnItemClickHandler, + dueActionsHandler = dueActionsHandler, + ) + } + } +} + +@Preview +@Composable +private fun Preview_EmptyState() { + IvyPreview { + val emptyState = defaultEmptyState() + val trnItemClickHandler = defaultTrnItemClickHandler() + val dueActionsHandler = defaultDueActionsHandler() + + LazyColumn { + transactionsList( + items = emptyList(), + emptyState = emptyState, + trnClickHandler = trnItemClickHandler, + dueActionsHandler = dueActionsHandler, + ) + } + } +} + +@Composable +fun sampleTrnListItems(): List = listOf( + dummyDueDividerUi( + label = "Upcoming", + type = DueDividerUiType.Upcoming, + income = dummyValueUi("16.99"), + expense = null + ), + dummyTransactionUi( + title = "Upcoming payment", + account = dummyAccountUi( + name = "Revolut", + color = Purple, + icon = dummyIconSized(R.drawable.ic_custom_revolut_s) + ), + category = dummyCategoryUi( + name = "Investments", + color = Blue2Light, + icon = dummyIconSized(R.drawable.ic_custom_leaf_s) + ), + value = dummyValueUi("16.99"), + type = TransactionType.Income, + time = dummyTrnTimeDueUi(timeNow().plusDays(1)) + ), + dummyDueDividerUi( + type = DueDividerUiType.Overdue, + label = "Overdue", + income = null, + expense = dummyValueUi("650.0"), + ), + dummyTransactionUi( + title = "Rent", + value = dummyValueUi("650.0"), + account = dummyAccountUi( + name = "Cash", + color = Green, + icon = dummyIconUnknown(R.drawable.ic_vue_money_coins) + ), + category = null, + type = TransactionType.Expense, + time = dummyTrnTimeDueUi() + ), + DateDividerUi( + id = "2021-01-01", + date = "September 25.", + dateContext = "Friday", + cashflow = SignedValueUi.Negative(dummyValueUi("30.0")), + collapsed = false, + ), + dummyTransactionUi( + title = "Food", + account = dummyAccountUi( + name = "Revolut", + color = Purple, + icon = dummyIconSized(R.drawable.ic_custom_revolut_s) + ), + category = dummyCategoryUi( + name = "Order food", + color = Orange2, + icon = dummyIconSized(R.drawable.ic_custom_orderfood_s) + ), + value = dummyValueUi("30.0"), + type = TransactionType.Expense, + time = dummyTrnTimeActualUi() + ), + DateDividerUi( + id = "2021-01-01", + date = "September 23.", + dateContext = "Wednesday", + cashflow = SignedValueUi.Positive(dummyValueUi("105.33")), + collapsed = false, + ), + dummyTransactionUi( + title = "Buy some cool gadgets", + description = "Premium tech!", + account = dummyAccountUi( + name = "Bank", + color = Red, + icon = dummyIconSized(R.drawable.ic_custom_bank_s) + ), + category = dummyCategoryUi( + name = "Tech", + color = Blue2Dark, + icon = dummyIconUnknown(R.drawable.ic_vue_edu_telescope) + ), + value = dummyValueUi("55.23"), + type = TransactionType.Expense, + ), + dummyTransactionUi( + title = "Ivy Apps revenue", + account = dummyAccountUi( + name = "Revolut Business", + color = Purple2Dark, + icon = dummyIconSized(R.drawable.ic_custom_revolut_s) + ), + category = null, + value = dummyValueUi("160.53"), + type = TransactionType.Income, + ), + dummyTransactionUi( + title = "Buy some cool gadgets", + description = "Premium tech!", + account = dummyAccountUi( + name = "Bank", + color = Red, + icon = dummyIconSized(R.drawable.ic_custom_bank_s) + ), + category = dummyCategoryUi( + name = "Tech", + color = Blue2Dark, + icon = dummyIconUnknown(R.drawable.ic_vue_edu_telescope) + ), + value = dummyValueUi("55.23"), + type = TransactionType.Expense, + ), +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/transaction/handling/DueActionsHandlerViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/transaction/handling/DueActionsHandlerViewModel.kt new file mode 100644 index 0000000..9511967 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/transaction/handling/DueActionsHandlerViewModel.kt @@ -0,0 +1,98 @@ +package com.ivy.core.ui.transaction.handling + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.toUUID +import com.ivy.core.domain.HandlerViewModel +import com.ivy.core.domain.action.transaction.TrnByIdAct +import com.ivy.core.domain.action.transaction.WriteTrnsAct +import com.ivy.core.domain.action.transaction.transfer.ModifyTransfer +import com.ivy.core.domain.action.transaction.transfer.TransferByBatchIdAct +import com.ivy.core.domain.action.transaction.transfer.WriteTransferAct +import com.ivy.core.ui.algorithm.trnhistory.data.TransactionUi +import com.ivy.core.ui.algorithm.trnhistory.data.TransferUi +import com.ivy.data.transaction.TrnTime +import com.ivy.design.util.hiltViewModelPreviewSafe +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@Immutable +data class DueActionsHandler( + val onExecuteTrn: (TransactionUi) -> Unit, + val onSkipTrn: (TransactionUi) -> Unit, + val onExecuteTransfer: (TransferUi) -> Unit, + val onSkipTransfer: (TransferUi) -> Unit, +) + +sealed interface DueActionEvent { + data class SkipTrn(val trn: TransactionUi) : DueActionEvent + data class ExecuteTrn(val trn: TransactionUi) : DueActionEvent + data class SkipTransfer(val trn: TransferUi) : DueActionEvent + data class ExecuteTransfer(val transfer: TransferUi) : DueActionEvent +} + +@HiltViewModel +class DueActionsHandlerViewModel @Inject constructor( + private val writeTransferAct: WriteTransferAct, + private val writeTrnsAct: WriteTrnsAct, + private val transferByBatchIdAct: TransferByBatchIdAct, + private val trnByIdAct: TrnByIdAct, + private val timeProvider: TimeProvider, +) : HandlerViewModel() { + override suspend fun handleEvent(event: DueActionEvent) = when (event) { + is DueActionEvent.ExecuteTransfer -> handleExecuteTransfer(event) + is DueActionEvent.SkipTransfer -> handleSkipTransfer(event) + is DueActionEvent.ExecuteTrn -> handleExecuteTrn(event) + is DueActionEvent.SkipTrn -> handleSkipTrn(event) + } + + private suspend fun handleExecuteTransfer(event: DueActionEvent.ExecuteTransfer) { + writeTransferAct( + ModifyTransfer.updateTrnTime( + batchId = event.transfer.batchId, + newTrnTime = TrnTime.Actual(timeProvider.timeNow()), + ) + ) + } + + private suspend fun handleSkipTransfer(event: DueActionEvent.SkipTransfer) { + val transfer = transferByBatchIdAct(event.trn.batchId) ?: return + writeTransferAct(ModifyTransfer.delete(transfer)) + } + + private suspend fun handleExecuteTrn(event: DueActionEvent.ExecuteTrn) { + val old = trnByIdAct(event.trn.id.toUUID()) ?: return + writeTrnsAct( + WriteTrnsAct.Input.Update( + old = old, + new = old.copy(time = TrnTime.Actual(timeProvider.timeNow())) + ) + ) + } + + private suspend fun handleSkipTrn(event: DueActionEvent.SkipTrn) { + writeTrnsAct(WriteTrnsAct.Input.DeleteInefficient(trnId = event.trn.id)) + } +} + +@Composable +fun defaultDueActionsHandler(): DueActionsHandler { + val viewModel: DueActionsHandlerViewModel? = hiltViewModelPreviewSafe() + + return DueActionsHandler( + onExecuteTrn = { + viewModel?.onEvent(DueActionEvent.ExecuteTrn(it)) + }, + onSkipTrn = { + viewModel?.onEvent(DueActionEvent.SkipTrn(it)) + }, + onExecuteTransfer = { + viewModel?.onEvent(DueActionEvent.ExecuteTransfer(it)) + }, + + onSkipTransfer = { + viewModel?.onEvent(DueActionEvent.SkipTransfer(it)) + }, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/transaction/handling/TrnClickHandlerViewModel.kt b/core/ui/src/main/java/com/ivy/core/ui/transaction/handling/TrnClickHandlerViewModel.kt new file mode 100644 index 0000000..ff0c20d --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/transaction/handling/TrnClickHandlerViewModel.kt @@ -0,0 +1,77 @@ +package com.ivy.core.ui.transaction.handling + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import com.ivy.core.domain.HandlerViewModel +import com.ivy.core.ui.algorithm.trnhistory.data.TransactionUi +import com.ivy.core.ui.algorithm.trnhistory.data.TransferUi +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.design.util.hiltViewModelPreviewSafe +import com.ivy.navigation.Navigator +import com.ivy.navigation.destinations.Destination +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@Immutable +data class TrnItemClickHandler( + val onTrnClick: (TransactionUi) -> Unit, + val onTransferClick: (TransferUi) -> Unit, + val onAccountClick: (AccountUi) -> Unit, + val onCategoryClick: (CategoryUi) -> Unit, +) + +sealed interface TrnItemClickEvent { + data class TransactionClick(val trn: TransactionUi) : TrnItemClickEvent + data class TransferClick(val transfer: TransferUi) : TrnItemClickEvent + data class AccountClick(val account: AccountUi) : TrnItemClickEvent + data class CategoryClick(val category: CategoryUi) : TrnItemClickEvent +} + +@HiltViewModel +class TrnItemClickHandlerViewModel @Inject constructor( + private val navigator: Navigator +) : HandlerViewModel() { + override suspend fun handleEvent(event: TrnItemClickEvent) = when (event) { + is TrnItemClickEvent.AccountClick -> handleAccountClick(event.account) + is TrnItemClickEvent.CategoryClick -> handleCategoryClick(event.category) + is TrnItemClickEvent.TransactionClick -> handleTransactionClick(event.trn) + is TrnItemClickEvent.TransferClick -> handleTransferClick(event.transfer) + } + + private fun handleAccountClick(account: AccountUi) { + navigator.navigate(Destination.accountTransactions.destination(account.id)) + } + + private fun handleCategoryClick(category: CategoryUi) { + navigator.navigate(Destination.categoryTransactions.destination(category.id)) + } + + private fun handleTransactionClick(transaction: TransactionUi) { + navigator.navigate(Destination.transaction.destination(transaction.id)) + } + + private fun handleTransferClick(transfer: TransferUi) { + navigator.navigate(Destination.transfer.destination(transfer.batchId)) + } +} + +@Composable +fun defaultTrnItemClickHandler(): TrnItemClickHandler { + val viewModel: TrnItemClickHandlerViewModel? = hiltViewModelPreviewSafe() + + return TrnItemClickHandler( + onAccountClick = { + viewModel?.onEvent(TrnItemClickEvent.AccountClick(it)) + }, + onCategoryClick = { + viewModel?.onEvent(TrnItemClickEvent.CategoryClick(it)) + }, + onTrnClick = { + viewModel?.onEvent(TrnItemClickEvent.TransactionClick(it)) + }, + onTransferClick = { + viewModel?.onEvent(TrnItemClickEvent.TransferClick(it)) + } + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/transaction/item/BaseTrnCard.kt b/core/ui/src/main/java/com/ivy/core/ui/transaction/item/BaseTrnCard.kt new file mode 100644 index 0000000..1369629 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/transaction/item/BaseTrnCard.kt @@ -0,0 +1,163 @@ +package com.ivy.core.ui.transaction.item + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.R +import com.ivy.core.ui.data.transaction.TrnTimeUi +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l1_buildingBlocks.CaptionSecond +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton + +/** + * See TransactionUi.Card() and TrnListItemUi.Transfer.Card(). + */ +@Composable +internal fun BaseTrnCard( + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(UI.shapes.rounded) + .background(UI.colors.medium, UI.shapes.rounded) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp) + .testTag("transaction_card"), + content = content + ) +} + +// region Due Date ("DUE ON ...") +@Composable +internal fun DueDate(time: TrnTimeUi) { + if (time is TrnTimeUi.Due) { + SpacerVer(height = 8.dp) + CaptionSecond( + text = time.dueOnDate, + color = if (time.upcoming) UI.colors.orange else UI.colors.red, + fontWeight = FontWeight.Bold + ) + } +} +//endregion + +// region Title & Description +@Composable +internal fun Title( + title: String?, + time: TrnTimeUi +) { + if (title != null) { + SpacerVer(height = if (time is TrnTimeUi.Due) 4.dp else 8.dp) + B2( + text = title, + fontWeight = FontWeight.ExtraBold + ) + } +} + +@Composable +internal fun Description( + description: String?, + title: String? +) { + if (description != null) { + SpacerVer(height = if (title != null) 0.dp else 4.dp) + CaptionSecond( + text = description, + color = UI.colors.neutral, + fontWeight = FontWeight.Bold, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } +} +//endregion + +// region Amount Row +@Composable +internal fun TransactionCardAmountRow( + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit +) { + Row( + modifier = modifier + .testTag("type_amount_currency"), + verticalAlignment = Alignment.CenterVertically, + content = content + ) +} +// endregion + +// region Due Payment CTAs +@Composable +internal fun DuePaymentCTAs( + time: TrnTimeUi, + cta: String, + onSkip: () -> Unit, + onExecute: () -> Unit, +) { + if (time is TrnTimeUi.Due) { + SpacerVer(height = 12.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), // additional padding to look better + verticalAlignment = Alignment.CenterVertically + ) { + SkipButton(onClick = onSkip) + SpacerHor(width = 12.dp) + ExecutePaymentButton(cta = cta, onClick = onExecute) + } + } +} + +@Composable +private fun RowScope.SkipButton( + onClick: () -> Unit +) { + IvyButton( + modifier = Modifier.weight(1f), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Negative, + text = stringResource(R.string.skip), + icon = null, + onClick = onClick, + ) +} + +@Composable +private fun RowScope.ExecutePaymentButton( + cta: String, + onClick: () -> Unit +) { + IvyButton( + modifier = Modifier.weight(1f), + size = ButtonSize.Big, + visibility = Visibility.High, + feeling = Feeling.Positive, + text = cta, + icon = null, + onClick = onClick, + ) +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/transaction/item/DateDivider.kt b/core/ui/src/main/java/com/ivy/core/ui/transaction/item/DateDivider.kt new file mode 100644 index 0000000..2793877 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/transaction/item/DateDivider.kt @@ -0,0 +1,144 @@ +package com.ivy.core.ui.transaction.item + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.* +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.common.time.dateId +import com.ivy.common.time.dateNowLocal +import com.ivy.core.domain.algorithm.trnhistory.toggleCollapseExpandTrnListKey +import com.ivy.core.domain.pure.format.SignedValueUi +import com.ivy.core.domain.pure.format.dummyValueUi +import com.ivy.core.ui.algorithm.trnhistory.data.DateDividerUi +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.B1 +import com.ivy.design.l1_buildingBlocks.B2Second +import com.ivy.design.l1_buildingBlocks.Caption +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.thenIf + +@Composable +fun DateDivider(divider: DateDividerUi) { + val primary = UI.colors.primary + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + toggleCollapseExpandTrnListKey(keyId = divider.id) + } + .thenIf(divider.collapsed) { + drawBehind { + val cornerRadius = CornerRadius(12.dp.toPx(), 12.dp.toPx()) + val path = Path().apply { + addRoundRect( + RoundRect( + rect = Rect( + offset = Offset.Zero, + size = Size( + width = 8.dp.toPx(), + height = size.height + ), + ), + topRight = cornerRadius, + bottomRight = cornerRadius, + ) + ) + } + drawPath(path, color = primary) + } + } + .padding(start = 24.dp, end = 32.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Date(date = divider.date, day = divider.dateContext) + Cashflow(cashflow = divider.cashflow) + } +} + +@Composable +private fun RowScope.Date( + date: String, + day: String, +) { + Column( + modifier = Modifier.weight(1f) + ) { + B1(text = date, fontWeight = FontWeight.ExtraBold) + Caption(text = day, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun Cashflow( + cashflow: SignedValueUi, +) { + + B2Second( + text = when (cashflow) { + is SignedValueUi.Negative -> "-${cashflow.value.amount} ${cashflow.value.currency}" + else -> "${cashflow.value.amount} ${cashflow.value.currency}" + }, + color = when (cashflow) { + is SignedValueUi.Positive -> UI.colors.green + else -> UI.colors.neutral + } + ) +} + + +// region Previews +@Preview +@Composable +private fun Preview_Positive() { + ComponentPreview { + DateDivider( + DateDividerUi( + id = dateNowLocal().dateId(), + date = "September 25.", + dateContext = "Today", + cashflow = SignedValueUi.Positive(dummyValueUi("154.32")), + collapsed = false, + ) + ) + } +} + +@Preview +@Composable +private fun Preview_Negative() { + ComponentPreview { + DateDivider( + DateDividerUi( + id = dateNowLocal().dateId(), + date = "September 25. 2020", + dateContext = "Today", + cashflow = SignedValueUi.Negative(dummyValueUi("154.32")), + collapsed = false, + ) + ) + } +} + +@Preview +@Composable +private fun Preview_Zero() { + ComponentPreview { + DateDivider( + DateDividerUi( + id = dateNowLocal().dateId(), + date = "September 25. 2020", + dateContext = "Today", + cashflow = SignedValueUi.Zero(dummyValueUi("0")), + collapsed = false, + ) + ) + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/transaction/item/DueSectionDivider.kt b/core/ui/src/main/java/com/ivy/core/ui/transaction/item/DueSectionDivider.kt new file mode 100644 index 0000000..9fed526 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/transaction/item/DueSectionDivider.kt @@ -0,0 +1,193 @@ +package com.ivy.core.ui.transaction.item + +import androidx.compose.animation.core.animateFloatAsState +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.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.dummyValueUi +import com.ivy.core.ui.algorithm.trnhistory.data.DueDividerUi +import com.ivy.core.ui.algorithm.trnhistory.data.DueDividerUiType +import com.ivy.core.ui.algorithm.trnhistory.data.dummyDueDividerUi +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.style +import com.ivy.design.l1_buildingBlocks.B1 +import com.ivy.design.l1_buildingBlocks.IconRes +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l3_ivyComponents.IvyDividerDot +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.springBounce +import com.ivy.resources.R + +@Composable +fun DueSectionDivider( + divider: DueDividerUi, + setExpanded: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + setExpanded(!divider.collapsed) + }, + verticalAlignment = Alignment.CenterVertically + ) { + SpacerHor(width = 24.dp) + Column( + modifier = Modifier.weight(1f) + ) { + Title( + title = divider.label, + color = when (divider.type) { + DueDividerUiType.Upcoming -> UI.colors.orange + DueDividerUiType.Overdue -> UI.colors.red + } + ) + SpacerVer(height = 4.dp) + DueIncomeExpense(income = divider.income, expense = divider.expense) + } + + ExpandCollapseIcon(expanded = !divider.collapsed) + SpacerHor(width = 32.dp) + } +} + +@Composable +private fun Title( + title: String, + color: Color, +) { + B1( + text = title, + modifier = Modifier + // TODO: Rename this to "due_title" + .testTag("upcoming_title"), + fontWeight = FontWeight.ExtraBold, + color = color, + ) +} + +@Composable +private fun ExpandCollapseIcon( + expanded: Boolean, +) { + val expandIconRotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + animationSpec = springBounce() + ) + IconRes( + icon = R.drawable.ic_expand_less, + modifier = Modifier.rotate(expandIconRotation) + ) +} + +@Composable +private fun DueIncomeExpense( + income: ValueUi?, + expense: ValueUi?, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + expense?.AmountCurrencyLabel( + testTag = "upcoming_expense", + label = stringResource(R.string.expenses_lowercase), + amountColor = UI.colorsInverted.pure, + ) + + if (income != null && expense != null) { + SpacerHor(width = 8.dp) + IvyDividerDot() + SpacerHor(width = 8.dp) + } + + income?.AmountCurrencyLabel( + testTag = "upcoming_income", + label = stringResource(R.string.income_lowercase), + amountColor = UI.colors.green, + ) + } +} + +@Composable +private fun ValueUi.AmountCurrencyLabel( + testTag: String, + label: String, + amountColor: Color +) { + Text( + modifier = Modifier.testTag(testTag), + text = "$amount $currency", + style = UI.typoSecond.c.style( + fontWeight = FontWeight.ExtraBold, + color = amountColor, + ) + ) + SpacerHor(width = 4.dp) + Text( + text = label, + style = UI.typo.c.style( + fontWeight = FontWeight.Normal, + color = UI.colorsInverted.pure + ) + ) +} + + +// region Previews +@Preview +@Composable +private fun Preview_Upcoming_IncomeExpenses() { + ComponentPreview { + DueSectionDivider( + divider = dummyDueDividerUi(), + setExpanded = {} + ) + } +} + +@Preview +@Composable +private fun Preview_Overdue_Expenses() { + ComponentPreview { + DueSectionDivider( + divider = dummyDueDividerUi( + income = null, + expense = dummyValueUi("943.70"), + type = DueDividerUiType.Overdue, + label = "Overdue" + ), + setExpanded = {} + ) + } +} + +@Preview +@Composable +private fun Preview_Upcoming_Income() { + ComponentPreview { + DueSectionDivider( + divider = dummyDueDividerUi( + expense = dummyValueUi("943.70"), + income = dummyValueUi("1,2k"), + ), + setExpanded = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/transaction/item/TransactionCard.kt b/core/ui/src/main/java/com/ivy/core/ui/transaction/item/TransactionCard.kt new file mode 100644 index 0000000..d20377c --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/transaction/item/TransactionCard.kt @@ -0,0 +1,237 @@ +package com.ivy.core.ui.transaction.item + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.ui.R +import com.ivy.core.ui.account.AccountBadge +import com.ivy.core.ui.algorithm.trnhistory.data.TransactionUi +import com.ivy.core.ui.algorithm.trnhistory.data.dummyTransactionUi +import com.ivy.core.ui.category.CategoryBadge +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.transaction.TrnTimeUi +import com.ivy.core.ui.data.transaction.dummyTrnTimeDueUi +import com.ivy.core.ui.value.AmountCurrency +import com.ivy.data.transaction.TransactionType +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.White +import com.ivy.design.l0_system.color.asBrush +import com.ivy.design.l1_buildingBlocks.IconRes +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.util.ComponentPreview + +@Composable +fun TransactionCard( + trn: TransactionUi, + modifier: Modifier = Modifier, + onClick: (TransactionUi) -> Unit, + onAccountClick: (AccountUi) -> Unit, + onCategoryClick: (CategoryUi) -> Unit, + onSkip: (TransactionUi) -> Unit, + onExecute: (TransactionUi) -> Unit, +) { + BaseTrnCard( + modifier = modifier, + onClick = { onClick(trn) } + ) { + IncomeExpenseHeader( + account = trn.account, + category = trn.category, + onCategoryClick = onCategoryClick, + onAccountClick = onAccountClick + ) + DueDate(time = trn.time) + Title(title = trn.title, time = trn.time) + Description(description = trn.description, title = trn.title) + TrnValue(type = trn.type, value = trn.value, time = trn.time) + + DuePaymentCTAs( + time = trn.time, + cta = when (trn.type) { + TransactionType.Income -> stringResource(R.string.get) + TransactionType.Expense -> stringResource(R.string.pay) + }, + onSkip = { onSkip(trn) }, + onExecute = { onExecute(trn) } + ) + } +} + +// region Transaction Header +@Composable +private fun IncomeExpenseHeader( + account: AccountUi, + category: CategoryUi?, + onCategoryClick: (CategoryUi) -> Unit, + onAccountClick: (AccountUi) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + category?.let { + CategoryBadge( + category = it, + onClick = { onCategoryClick(category) } + ) + SpacerHor(width = 8.dp) + } + + AccountBadge( + account = account, + background = UI.colors.pure, + onClick = { onAccountClick(account) } + ) + } +} +// endregion + +// region TrnType & Amount +@Composable +private fun TrnValue( + value: ValueUi, + type: TransactionType, + time: TrnTimeUi, +) { + SpacerVer(height = 8.dp) + TransactionCardAmountRow { + TrnTypeIcon(type = type, time = time) + SpacerHor(width = 12.dp) + AmountCurrency( + value = value, + color = when (type) { + TransactionType.Income -> UI.colors.green + TransactionType.Expense -> when (time) { + is TrnTimeUi.Actual -> UI.colorsInverted.pure + is TrnTimeUi.Due -> if (time.upcoming) + UI.colors.orange else UI.colors.red + } + } + ) + } +} + +@Composable +private fun TrnTypeIcon( + type: TransactionType, + time: TrnTimeUi +) { + data class StyledIcon( + @DrawableRes + val iconId: Int, + val bgColor: Brush, + val tint: Color, + ) + + val style = when (type) { + TransactionType.Income -> StyledIcon( + iconId = R.drawable.ic_income, + bgColor = UI.colors.green.asBrush(), + tint = White + ) + TransactionType.Expense -> { + StyledIcon( + iconId = R.drawable.ic_expense, + bgColor = when (time) { + is TrnTimeUi.Actual -> UI.colors.red.asBrush() + is TrnTimeUi.Due -> if (time.upcoming) + UI.colors.orange.asBrush() else UI.colors.red.asBrush() + }, + tint = White + ) + } + } + + IconRes( + modifier = Modifier + .background(style.bgColor, UI.shapes.circle), + icon = style.iconId, + tint = style.tint, + contentDescription = "transactionType" + ) +} +// endregion + +// region Previews +@Preview +@Composable +private fun Preview_Expense() { + ComponentPreview { + TransactionCard( + modifier = Modifier.padding(horizontal = 16.dp), + trn = dummyTransactionUi( + type = TransactionType.Expense, + value = ValueUi( + amount = "0.34", + currency = "BGN" + ), + title = "Order food" + ), + onClick = {}, + onAccountClick = {}, + onCategoryClick = {}, + onExecute = {}, + onSkip = {} + ) + } +} + +@Preview +@Composable +private fun Preview_Income() { + ComponentPreview { + TransactionCard( + modifier = Modifier.padding(horizontal = 16.dp), + trn = dummyTransactionUi( + type = TransactionType.Income, + value = ValueUi( + amount = "1,005.00", + currency = "USD" + ), + title = "Income" + ), + onClick = {}, + onAccountClick = {}, + onCategoryClick = {}, + onExecute = {}, + onSkip = {} + ) + } +} + +@Preview +@Composable +private fun Preview_UpcomingExpense() { + ComponentPreview { + TransactionCard( + modifier = Modifier.padding(horizontal = 16.dp), + trn = dummyTransactionUi( + type = TransactionType.Expense, + value = ValueUi( + amount = "1,005.00", + currency = "USD" + ), + title = "Upcoming Expense", + description = "Description", + time = dummyTrnTimeDueUi(), + ), + onClick = {}, + onAccountClick = {}, + onCategoryClick = {}, + onExecute = {}, + onSkip = {}, + ) + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/transaction/item/TransferCard.kt b/core/ui/src/main/java/com/ivy/core/ui/transaction/item/TransferCard.kt new file mode 100644 index 0000000..3024672 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/transaction/item/TransferCard.kt @@ -0,0 +1,285 @@ +package com.ivy.core.ui.transaction.item + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.dummyValueUi +import com.ivy.core.ui.R +import com.ivy.core.ui.algorithm.trnhistory.data.TransferUi +import com.ivy.core.ui.category.CategoryBadge +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.core.ui.data.dummyCategoryUi +import com.ivy.core.ui.data.icon.IconSize +import com.ivy.core.ui.data.transaction.dummyTrnTimeActualUi +import com.ivy.core.ui.icon.ItemIcon +import com.ivy.core.ui.value.AmountCurrency +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.White +import com.ivy.design.l0_system.color.rememberContrast +import com.ivy.design.l1_buildingBlocks.* +import com.ivy.design.util.ComponentPreview + +@Composable +fun TransferCard( + transfer: TransferUi, + modifier: Modifier = Modifier, + onAccountClick: (AccountUi) -> Unit, + onCategoryClick: (CategoryUi) -> Unit, + onClick: (TransferUi) -> Unit, + onExecuteTransfer: (TransferUi) -> Unit, + onSkipTransfer: (TransferUi) -> Unit, +) { + BaseTrnCard( + modifier = modifier, + onClick = { onClick(transfer) } + ) { + TransferHeader( + fromAccount = transfer.fromAccount, + toAccount = transfer.toAccount, + onAccountClick = onAccountClick + ) + Category(category = transfer.category, onCategoryClick = onCategoryClick) + DueDate(time = transfer.time) + Title(title = transfer.title, time = transfer.time) + Description(description = transfer.description, title = transfer.title) + TransferAmount(fromValue = transfer.fromAmount) + ToAmountReceived( + fromValue = transfer.fromAmount, + toValue = transfer.toAmount + ) + Fee(fee = transfer.fee) + DuePaymentCTAs( + time = transfer.time, + cta = "Execute", + onSkip = { + onSkipTransfer(transfer) + }, + onExecute = { + onExecuteTransfer(transfer) + }, + ) + } +} + +@Composable +private fun TransferHeader( + fromAccount: AccountUi, + toAccount: AccountUi, + onAccountClick: (AccountUi) -> Unit, +) { + @Composable + fun IconAndName( + account: AccountUi, + horizontalArrangement: Arrangement.Horizontal, + modifier: Modifier = Modifier, + onClick: () -> Unit + ) { + Row( + modifier = modifier + .clip(UI.shapes.fullyRounded) + .clickable(onClick = onClick), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = horizontalArrangement, + ) { + ItemIcon( + itemIcon = account.icon, + size = IconSize.S, + tint = UI.colorsInverted.pure, + ) + SpacerHor(width = 4.dp) + Caption( + text = account.name, + color = UI.colorsInverted.pure, + fontWeight = FontWeight.ExtraBold + ) + } + } + + Row( + modifier = Modifier + .background(UI.colors.pure, UI.shapes.fullyRounded) + .padding(start = 8.dp, end = 20.dp) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconAndName( + modifier = Modifier.weight(1f), + account = fromAccount, + horizontalArrangement = Arrangement.Start, + onClick = { onAccountClick(fromAccount) } + ) + + SpacerHor(width = 12.dp) + IconRes(R.drawable.ic_arrow_right) + SpacerHor(width = 8.dp) + + IconAndName( + modifier = Modifier.weight(1f), + account = toAccount, + horizontalArrangement = Arrangement.End, + onClick = { onAccountClick(toAccount) } + ) + } +} + +@Composable +private fun Category( + category: CategoryUi?, + onCategoryClick: (CategoryUi) -> Unit, +) { + if (category != null) { + SpacerVer(height = 8.dp) + CategoryBadge(category = category, onClick = { onCategoryClick(category) }) + } +} + +@Composable +private fun TransferAmount( + fromValue: ValueUi, +) { + SpacerVer(height = 8.dp) + TransactionCardAmountRow { + IconRes( + modifier = Modifier.background(UI.colors.primary, UI.shapes.circle), + icon = R.drawable.ic_transfer, + tint = rememberContrast(UI.colors.primary), + ) + SpacerHor(width = 12.dp) + AmountCurrency(value = fromValue, color = UI.colors.primary) + } +} + +@Composable +private fun ToAmountReceived( + fromValue: ValueUi, + toValue: ValueUi?, +) { + if (toValue != null && fromValue != toValue) { + B2Second( + text = "${toValue.amount} ${toValue.currency}", + modifier = Modifier.padding(start = 44.dp), + color = UI.colors.neutral, + fontWeight = FontWeight.Normal, + ) + } +} + +@Composable +private fun Fee( + fee: ValueUi?, +) { + if (fee != null) { + SpacerVer(height = 8.dp) + TransactionCardAmountRow { + IconRes( + modifier = Modifier.background(UI.colors.red, UI.shapes.circle), + icon = R.drawable.ic_expense, + tint = White + ) + SpacerHor(width = 12.dp) + AmountCurrency(value = fee, color = UI.colors.red) + SpacerHor(width = 4.dp) + B2Second( + text = "(FEE)", + color = UI.colors.red, + fontWeight = FontWeight.Normal + ) + } + } +} + + +// region Previews +@Preview +@Composable +private fun Preview_SameCurrency() { + ComponentPreview { + TransferCard( + modifier = Modifier.padding(horizontal = 16.dp), + transfer = TransferUi( + batchId = "", + time = dummyTrnTimeActualUi(), + fromAmount = dummyValueUi("400"), + toAmount = dummyValueUi("400"), + fee = null, + category = null, + description = null, + title = null, + fromAccount = dummyAccountUi(), + toAccount = dummyAccountUi(), + ), + onAccountClick = {}, + onCategoryClick = {}, + onClick = {}, + onSkipTransfer = {}, + onExecuteTransfer = {} + ) + } +} + +@Preview +@Composable +private fun Preview_Detailed() { + ComponentPreview { + TransferCard( + modifier = Modifier.padding(horizontal = 16.dp), + transfer = TransferUi( + batchId = "", + time = dummyTrnTimeActualUi(), + title = "Withdrawing cash", + description = "So I can pay rent", + category = dummyCategoryUi(), + fromAmount = dummyValueUi(amount = "400", currency = "EUR"), + fromAccount = dummyAccountUi(), + toAmount = dummyValueUi(amount = "800", currency = "BGN"), + toAccount = dummyAccountUi(), + fee = dummyValueUi("2") + ), + onAccountClick = {}, + onCategoryClick = {}, + onClick = {}, + onExecuteTransfer = {}, + onSkipTransfer = {} + ) + } +} + +@Preview +@Composable +private fun Preview_LongAccount_names() { + ComponentPreview { + TransferCard( + modifier = Modifier.padding(horizontal = 16.dp), + transfer = TransferUi( + batchId = "", + time = dummyTrnTimeActualUi(), + fromAccount = dummyAccountUi(name = "My very long account name"), + fromAmount = dummyValueUi(amount = "400", currency = "EUR"), + toAccount = dummyAccountUi(name = "Revolut Business Company Account"), + toAmount = dummyValueUi(amount = "800", currency = "BGN"), + fee = null, + title = null, + description = null, + category = null, + ), + onAccountClick = {}, + onCategoryClick = {}, + onClick = {}, + onSkipTransfer = {}, + onExecuteTransfer = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/ivy/core/ui/value/AmountCurrency.kt b/core/ui/src/main/java/com/ivy/core/ui/value/AmountCurrency.kt new file mode 100644 index 0000000..0c2e260 --- /dev/null +++ b/core/ui/src/main/java/com/ivy/core/ui/value/AmountCurrency.kt @@ -0,0 +1,95 @@ +package com.ivy.core.ui.value + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.B1Second +import com.ivy.design.l1_buildingBlocks.B2Second +import com.ivy.design.l1_buildingBlocks.H2Second +import com.ivy.design.l1_buildingBlocks.SpacerHor + +@Suppress("unused") +@Composable +fun ColumnScope.AmountCurrencyBig( + value: ValueUi, + color: Color = UI.colorsInverted.pure, +) { + H2Second( + text = value.amount, + modifier = Modifier.testTag("amount_currency_b1"), + fontWeight = FontWeight.Bold, + color = color, + ) + B1Second( + text = value.currency, + fontWeight = FontWeight.Normal, + color = color, + ) +} + +@Suppress("unused") +@Composable +fun RowScope.AmountCurrencyBig( + value: ValueUi, + color: Color = UI.colorsInverted.pure, +) { + H2Second( + text = value.amount, + modifier = Modifier.testTag("amount_currency_b1"), + fontWeight = FontWeight.Bold, + color = color, + ) + SpacerHor(width = 4.dp) + B1Second( + text = value.currency, + fontWeight = FontWeight.SemiBold, + color = color, + ) +} + +@Suppress("unused") +@Composable +fun RowScope.AmountCurrency( + value: ValueUi, + color: Color = UI.colorsInverted.pure, +) { + B1Second( + text = value.amount, + modifier = Modifier.testTag("amount_currency_b1"), + fontWeight = FontWeight.Bold, + color = color, + ) + SpacerHor(width = 4.dp) + B1Second( + text = value.currency, + fontWeight = FontWeight.Normal, + color = color, + ) +} + +@Suppress("unused") +@Composable +fun RowScope.AmountCurrencySmall( + value: ValueUi, + color: Color = UI.colorsInverted.pure, +) { + B2Second( + text = value.amount, + modifier = Modifier.testTag("amount_currency_b1"), + fontWeight = FontWeight.Bold, + color = color, + ) + SpacerHor(width = 4.dp) + B2Second( + text = value.currency, + fontWeight = FontWeight.Normal, + color = color, + ) +} \ No newline at end of file diff --git a/debug.jks b/debug.jks new file mode 100644 index 0000000..0c1bc5b Binary files /dev/null and b/debug.jks differ diff --git a/debug/.gitignore b/debug/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/debug/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/debug/README.md b/debug/README.md new file mode 100644 index 0000000..9bcb7a9 --- /dev/null +++ b/debug/README.md @@ -0,0 +1,3 @@ +# Debug + +Dummy module **only for debug and testing purposes.** \ No newline at end of file diff --git a/debug/build.gradle.kts b/debug/build.gradle.kts new file mode 100644 index 0000000..3c7c42b --- /dev/null +++ b/debug/build.gradle.kts @@ -0,0 +1,17 @@ +import com.ivy.buildsrc.Hilt + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + implementation(project(":common:main")) + implementation(project(":core:domain")) + implementation(project(":core:ui")) + implementation(project(":navigation")) + implementation(project(":design-system")) + Hilt() +} \ No newline at end of file diff --git a/debug/src/main/AndroidManifest.xml b/debug/src/main/AndroidManifest.xml new file mode 100644 index 0000000..50a8555 --- /dev/null +++ b/debug/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/debug/src/main/java/com/ivy/debug/TestEvent.kt b/debug/src/main/java/com/ivy/debug/TestEvent.kt new file mode 100644 index 0000000..179df81 --- /dev/null +++ b/debug/src/main/java/com/ivy/debug/TestEvent.kt @@ -0,0 +1,7 @@ +package com.ivy.debug + +import com.ivy.data.CurrencyCode + +sealed interface TestEvent { + data class BaseCurrencyChange(val currency: CurrencyCode) : TestEvent +} \ No newline at end of file diff --git a/debug/src/main/java/com/ivy/debug/TestScreen.kt b/debug/src/main/java/com/ivy/debug/TestScreen.kt new file mode 100644 index 0000000..0f90cd0 --- /dev/null +++ b/debug/src/main/java/com/ivy/debug/TestScreen.kt @@ -0,0 +1,109 @@ +package com.ivy.debug + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ivy.core.ui.amount.AmountModal +import com.ivy.core.ui.color.ColorPickerButton +import com.ivy.core.ui.color.picker.ColorPickerModal +import com.ivy.core.ui.currency.CurrencyPickerModal +import com.ivy.core.ui.icon.picker.IconPickerModal +import com.ivy.data.ItemIconId +import com.ivy.data.Value +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.Purple +import com.ivy.design.l1_buildingBlocks.ColumnRoot +import com.ivy.design.l1_buildingBlocks.H2 +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l1_buildingBlocks.SpacerWeight +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton + +@Composable +fun BoxScope.TestScreen() { + val viewModel: TestViewModel = hiltViewModel() + val state by viewModel.uiState.collectAsState() + + val iconPickerModal = rememberIvyModal() + val colorPickerModal = rememberIvyModal() + val amountModal = rememberIvyModal() + val currencyPickerModal = rememberIvyModal() + + var selectedIconId by remember { mutableStateOf(null) } + var selectedColor by remember { mutableStateOf(Purple) } + + ColumnRoot( + modifier = Modifier.padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SpacerWeight(weight = 1f) + IvyButton( + size = ButtonSize.Big, + visibility = Visibility.Focused, + feeling = Feeling.Positive, + text = "Pick an icon", + icon = null + ) { + iconPickerModal.show() + } + SpacerVer(height = 48.dp) + selectedIconId?.let { H2(text = it) } + SpacerVer(height = 48.dp) + ColorPickerButton( + colorPickerModal = colorPickerModal, + selectedColor = selectedColor + ) + SpacerVer(height = 48.dp) + IvyButton( + size = ButtonSize.Big, + visibility = Visibility.Focused, + feeling = Feeling.Positive, + text = "Amount modal", + icon = null + ) { + amountModal.show() + } + SpacerVer(height = 48.dp) + IvyButton( + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = "Base currency: ${state.baseCurrency}", + icon = null + ) { + currencyPickerModal.show() + } + SpacerWeight(weight = 1f) + } + + IconPickerModal( + modal = iconPickerModal, + initialIcon = null, + color = UI.colors.primary, + onIconPick = { selectedIconId = it } + ) + ColorPickerModal( + modal = colorPickerModal, + initialColor = selectedColor, + onColorPicked = { selectedColor = it } + ) + AmountModal( + modal = amountModal, + initialAmount = Value(0.0, "USD"), + onAmountEnter = {} + ) + CurrencyPickerModal( + modal = currencyPickerModal, + initialCurrency = state.baseCurrency, + onCurrencyPick = { + viewModel.onEvent(TestEvent.BaseCurrencyChange(it)) + } + ) +} \ No newline at end of file diff --git a/debug/src/main/java/com/ivy/debug/TestStateUi.kt b/debug/src/main/java/com/ivy/debug/TestStateUi.kt new file mode 100644 index 0000000..7cd86cc --- /dev/null +++ b/debug/src/main/java/com/ivy/debug/TestStateUi.kt @@ -0,0 +1,9 @@ +package com.ivy.debug + +import com.ivy.core.ui.data.period.SelectedPeriodUi +import com.ivy.data.CurrencyCode + +data class TestStateUi( + val selectedPeriodUi: SelectedPeriodUi, + val baseCurrency: CurrencyCode, +) \ No newline at end of file diff --git a/debug/src/main/java/com/ivy/debug/TestViewModel.kt b/debug/src/main/java/com/ivy/debug/TestViewModel.kt new file mode 100644 index 0000000..dae5d5c --- /dev/null +++ b/debug/src/main/java/com/ivy/debug/TestViewModel.kt @@ -0,0 +1,49 @@ +package com.ivy.debug + +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.action.period.SelectedPeriodFlow +import com.ivy.core.domain.action.settings.basecurrency.BaseCurrencyFlow +import com.ivy.core.domain.action.settings.basecurrency.WriteBaseCurrencyAct +import com.ivy.core.domain.pure.time.allTime +import com.ivy.core.ui.action.mapping.MapSelectedPeriodUiAct +import com.ivy.core.ui.data.period.SelectedPeriodUi +import com.ivy.core.ui.data.period.TimeRangeUi +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +@HiltViewModel +class TestViewModel @Inject constructor( + selectedPeriodFlow: SelectedPeriodFlow, + private val mapSelectedPeriodAct: MapSelectedPeriodUiAct, + private val writeBaseCurrencyAct: WriteBaseCurrencyAct, + baseCurrencyFlow: BaseCurrencyFlow, +) : SimpleFlowViewModel() { + override val initialUi: TestStateUi = TestStateUi( + selectedPeriodUi = SelectedPeriodUi.AllTime( + periodBtnText = "", + rangeUi = TimeRangeUi(allTime(), "", "") + ), + baseCurrency = "" + ) + + override val uiFlow: Flow = combine( + selectedPeriodFlow(), baseCurrencyFlow() + ) { period, baseCurrency -> + TestStateUi( + selectedPeriodUi = mapSelectedPeriodAct(period), + baseCurrency = baseCurrency, + ) + } + + // region Event handling + override suspend fun handleEvent(event: TestEvent) = when (event) { + is TestEvent.BaseCurrencyChange -> handleBaseCurrencyChange(event) + } + + private suspend fun handleBaseCurrencyChange(event: TestEvent.BaseCurrencyChange) { + writeBaseCurrencyAct(event.currency) + } + // endregion +} \ No newline at end of file diff --git a/design-system/.gitignore b/design-system/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/design-system/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/design-system/README.md b/design-system/README.md new file mode 100644 index 0000000..84434ff --- /dev/null +++ b/design-system/README.md @@ -0,0 +1,8 @@ +# Design System + +Ivy Wallet's Design System that implements the `IvyDesign` standard which covers: +- colors +- shapes +- typography + +Provides the building Ivy UI components, the `Ivy Wallet` theme and UI logic for picking contrast/dynamic colors. \ No newline at end of file diff --git a/design-system/build.gradle.kts b/design-system/build.gradle.kts new file mode 100644 index 0000000..d5c2dd5 --- /dev/null +++ b/design-system/build.gradle.kts @@ -0,0 +1,66 @@ +import com.ivy.buildsrc.AndroidX +import com.ivy.buildsrc.Compose +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Lifecycle + +apply() + +plugins { + `android-library` + id("org.jetbrains.kotlin.android") + id("kotlin-android") + id("kotlin-kapt") +} + +android { + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = true + + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = com.ivy.buildsrc.Versions.composeCompilerVersion + } + + packagingOptions { + //Exclude this files so Jetpack Compose UI tests can build + resources.excludes.add("META-INF/AL2.0") + resources.excludes.add("META-INF/LGPL2.1") + //------------------------------------------------------- + } +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":core:data-model")) + implementation(project(":resources")) + + Compose(api = true) + AndroidX(api = true) + Lifecycle(api = true) +} \ No newline at end of file diff --git a/design-system/consumer-rules.pro b/design-system/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/design-system/proguard-rules.pro b/design-system/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/design-system/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/design-system/src/main/AndroidManifest.xml b/design-system/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7051f1f --- /dev/null +++ b/design-system/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/animation/AnimationUtil.kt b/design-system/src/main/java/com/ivy/design/animation/AnimationUtil.kt new file mode 100644 index 0000000..cdb4701 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/animation/AnimationUtil.kt @@ -0,0 +1,8 @@ +package com.ivy.design.animation + +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically + +fun slideInBottom() = slideInVertically { it } + +fun slideOutBottom() = slideOutVertically { it } \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/api/IvyDesign.kt b/design-system/src/main/java/com/ivy/design/api/IvyDesign.kt new file mode 100644 index 0000000..865a5eb --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/api/IvyDesign.kt @@ -0,0 +1,15 @@ +package com.ivy.design.api + +import androidx.compose.runtime.Immutable +import com.ivy.design.l0_system.IvyShapes +import com.ivy.design.l0_system.IvyTypography +import com.ivy.design.l0_system.color.IvyColors + +@Immutable +data class IvyDesign( + val typography: IvyTypography, + val typographySecondary: IvyTypography, + val colors: IvyColors, + val colorsInverted: IvyColors, + val shapes: IvyShapes, +) \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/api/IvyUI.kt b/design-system/src/main/java/com/ivy/design/api/IvyUI.kt new file mode 100644 index 0000000..adc3b70 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/api/IvyUI.kt @@ -0,0 +1,47 @@ +package com.ivy.design.api + +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.BoxWithConstraintsScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.ivy.data.Theme +import com.ivy.design.api.systems.ivyWalletDesign +import com.ivy.design.l0_system.IvyTheme +import com.ivy.design.l0_system.UI + +private val appDesign = mutableStateOf( + ivyWalletDesign(theme = Theme.Auto, isSystemInDarkTheme = false) +) + +fun setAppDesign(design: IvyDesign) { + appDesign.value = design +} + +@Composable +fun IvyUI( + Content: @Composable (BoxWithConstraintsScope.() -> Unit) +) { + IvyTheme(design = appDesign.value) { + val systemUiController = rememberSystemUiController() + val useDarkIcons = UI.colors.isLight + + SideEffect { + systemUiController.setSystemBarsColor( + color = Color.Transparent, + darkIcons = useDarkIcons + ) + } + + Surface(modifier = Modifier.fillMaxSize()) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + Content() + } + } + } +} diff --git a/design-system/src/main/java/com/ivy/design/api/systems/IvyWalletDesign.kt b/design-system/src/main/java/com/ivy/design/api/systems/IvyWalletDesign.kt new file mode 100644 index 0000000..38efffc --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/api/systems/IvyWalletDesign.kt @@ -0,0 +1,226 @@ +package com.ivy.design.api.systems + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ivy.data.Theme +import com.ivy.design.R +import com.ivy.design.api.IvyDesign +import com.ivy.design.l0_system.IvyShapes +import com.ivy.design.l0_system.IvyTypography +import com.ivy.design.l0_system.color.* + + +fun ivyWalletDesign(theme: Theme, isSystemInDarkTheme: Boolean): IvyDesign = IvyDesign( + typography = typography(), + typographySecondary = typographySecondary(), + colors = colors(theme = theme, isSystemInDarkTheme = isSystemInDarkTheme), + colorsInverted = colors( + theme = when (theme) { + Theme.Light -> Theme.Dark + Theme.Dark -> Theme.Light + Theme.Auto -> Theme.Auto + }, + isSystemInDarkTheme = !isSystemInDarkTheme + ), + shapes = shapes() +) + +// region Typography +private const val OPEN_SANS_BASELINE_SHIFT = 0.075f +private const val RALE_WAY_BASELINE_SHIFT = 0.2f + +private val h1 = 40.sp +private val h2 = 32.sp +private val b1 = 20.sp +private val b2 = 16.sp +private val c = 12.sp + + +private fun typography(): IvyTypography { + val raleWay = FontFamily( + Font(R.font.raleway_regular, FontWeight.Normal), + Font(R.font.raleway_medium, FontWeight.Medium), + Font(R.font.raleway_black, FontWeight.Black), + Font(R.font.raleway_light, FontWeight.Light), + Font(R.font.raleway_semibold, FontWeight.SemiBold), + Font(R.font.raleway_bold, FontWeight.Bold), + Font(R.font.raleway_extrabold, FontWeight.ExtraBold), + ) + + return IvyTypography( + h1 = TextStyle( + fontFamily = raleWay, + fontWeight = FontWeight.Black, + fontSize = h1, + baselineShift = BaselineShift(RALE_WAY_BASELINE_SHIFT), + ), + h2 = TextStyle( + fontFamily = raleWay, + fontWeight = FontWeight.ExtraBold, + fontSize = h2, + baselineShift = BaselineShift(RALE_WAY_BASELINE_SHIFT), + ), + b1 = TextStyle( + fontFamily = raleWay, + fontWeight = FontWeight.Bold, + fontSize = b1, + baselineShift = BaselineShift(RALE_WAY_BASELINE_SHIFT), + ), + b2 = TextStyle( + fontFamily = raleWay, + fontWeight = FontWeight.Medium, + fontSize = b2, + baselineShift = BaselineShift(RALE_WAY_BASELINE_SHIFT), + ), + c = TextStyle( + fontFamily = raleWay, + fontWeight = FontWeight.ExtraBold, + fontSize = c, + baselineShift = BaselineShift(RALE_WAY_BASELINE_SHIFT), + ), + ) +} + +private fun typographySecondary(): IvyTypography { + val openSans = FontFamily( + Font(R.font.opensans_regular, FontWeight.Normal), + Font(R.font.opensans_regular, FontWeight.Medium), + Font(R.font.opensans_bold, FontWeight.Black), + Font(R.font.opensans_semibold, FontWeight.SemiBold), + Font(R.font.opensans_bold, FontWeight.Bold), + Font(R.font.opensans_extrabold, FontWeight.ExtraBold), + ) + + return IvyTypography( + h1 = TextStyle( + fontFamily = openSans, + fontWeight = FontWeight.Bold, + fontSize = h1, + baselineShift = BaselineShift(OPEN_SANS_BASELINE_SHIFT), + ), + h2 = TextStyle( + fontFamily = openSans, + fontWeight = FontWeight.Bold, + fontSize = h2, + baselineShift = BaselineShift(OPEN_SANS_BASELINE_SHIFT), + ), + b1 = TextStyle( + fontFamily = openSans, + fontWeight = FontWeight.Bold, + fontSize = b1, + baselineShift = BaselineShift(OPEN_SANS_BASELINE_SHIFT), + ), + b2 = TextStyle( + fontFamily = openSans, + fontWeight = FontWeight.Normal, + fontSize = b2, + baselineShift = BaselineShift(OPEN_SANS_BASELINE_SHIFT), + ), + c = TextStyle( + fontFamily = openSans, + fontWeight = FontWeight.Bold, + fontSize = c, + baselineShift = BaselineShift(OPEN_SANS_BASELINE_SHIFT), + ) + ) +} +// endregion + +// region Colors +private fun colors(theme: Theme, isSystemInDarkTheme: Boolean): IvyColors = when (theme) { + Theme.Light -> IvyColors( + pure = White, + neutral = Gray, + medium = MediumWhite, + + primary = Purple, + primaryP1 = Purple2Light, + primaryP2 = Purple1Light, + + red = Red, + redP1 = Red2Light, + redP2 = RedLight, + + orange = Orange, + orangeP1 = Orange2Light, + orangeP2 = OrangeLight, + + yellow = Yellow, + yellowP1 = YellowP1Light, + yellowP2 = YellowLight, + + green = Green, + greenP1 = Green2Light, + greenP2 = GreenLight, + + blue = Blue, + blueP1 = Blue2Light, + blueP2 = BlueLight, + + purple = Purple, + purpleP1 = Purple2Light, + purpleP2 = Purple1Light, + + isLight = true + ) + Theme.Dark -> IvyColors( + pure = Black, + neutral = Gray, + medium = MediumBlack, + + primary = Purple, + primaryP1 = Purple2Dark, + primaryP2 = Purple1Dark, + + red = Red, + redP1 = Red2Dark, + redP2 = RedDark, + + orange = Orange, + orangeP1 = Orange2Dark, + orangeP2 = OrangeDark, + + yellow = Yellow, + yellowP1 = YellowP1Dark, + yellowP2 = YellowDark, + + green = Green, + greenP1 = Green2Dark, + greenP2 = GreenDark, + + blue = Blue, + blueP1 = Blue2Dark, + blueP2 = BlueDark, + + purple = Purple, + purpleP1 = Purple2Dark, + purpleP2 = Purple1Dark, + + isLight = false + ) + Theme.Auto -> if (isSystemInDarkTheme) + colors(Theme.Dark, true) else + colors(Theme.Light, false) +} +// endregion + +private fun shapes(): IvyShapes { + val rSquared = 8.dp + val rRounded = 24.dp + + return IvyShapes( + squared = RoundedCornerShape(rSquared), + squaredTop = RoundedCornerShape(topStart = rSquared, topEnd = rSquared), + squaredBottom = RoundedCornerShape(bottomStart = rSquared, bottomEnd = rSquared), + rounded = RoundedCornerShape(rRounded), + roundedTop = RoundedCornerShape(topStart = rRounded, topEnd = rRounded), + roundedBottom = RoundedCornerShape(bottomStart = rRounded, bottomEnd = rRounded), + fullyRounded = RoundedCornerShape(percent = 50), + ) +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l0_system/IvyShapes.kt b/design-system/src/main/java/com/ivy/design/l0_system/IvyShapes.kt new file mode 100644 index 0000000..a18a704 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l0_system/IvyShapes.kt @@ -0,0 +1,21 @@ +package com.ivy.design.l0_system + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Immutable + +@Immutable +data class IvyShapes( + val squared: CornerBasedShape, + val squaredTop: CornerBasedShape, + val squaredBottom: CornerBasedShape, + + val rounded: CornerBasedShape, + val roundedTop: CornerBasedShape, + val roundedBottom: CornerBasedShape, + + val fullyRounded: CornerBasedShape, + + val circle: RoundedCornerShape = CircleShape +) diff --git a/design-system/src/main/java/com/ivy/design/l0_system/IvyTheme.kt b/design-system/src/main/java/com/ivy/design/l0_system/IvyTheme.kt new file mode 100644 index 0000000..6fc908f --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l0_system/IvyTheme.kt @@ -0,0 +1,110 @@ +package com.ivy.design.l0_system + +import androidx.compose.material.Colors +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Shapes +import androidx.compose.material.Typography +import androidx.compose.runtime.* +import com.ivy.design.api.IvyDesign +import com.ivy.design.l0_system.color.IvyColors +import com.ivy.design.l0_system.color.contrastColor + +val LocalIvyColors = compositionLocalOf { error("No colors") } +val LocalIvyColorsInverted = compositionLocalOf { error("No inverted colors") } +val LocalIvyTypo = compositionLocalOf { error("No typography") } +val LocalIvyTypoSecondary = compositionLocalOf { error("No secondary typography") } +val LocalIvyShapes = compositionLocalOf { error("No shapes") } + +object UI { + val colors: IvyColors + @Composable + @ReadOnlyComposable + get() = LocalIvyColors.current + + val colorsInverted: IvyColors + @Composable + @ReadOnlyComposable + get() = LocalIvyColorsInverted.current + + val typo: IvyTypography + @Composable + @ReadOnlyComposable + get() = LocalIvyTypo.current + + val typoSecond: IvyTypography + @Composable + @ReadOnlyComposable + get() = LocalIvyTypoSecondary.current + + + val shapes: IvyShapes + @Composable + @ReadOnlyComposable + get() = LocalIvyShapes.current +} + +@Composable +fun IvyTheme( + design: IvyDesign, + content: @Composable () -> Unit +) { + val colors = design.colors + val colorsInverted = design.colorsInverted + val typography = design.typography + val shapes = design.shapes + + CompositionLocalProvider( + LocalIvyColors provides colors, + LocalIvyColorsInverted provides colorsInverted, + LocalIvyTypo provides typography, + LocalIvyTypoSecondary provides design.typographySecondary, + LocalIvyShapes provides shapes + ) { + val materialColors = remember(colors, colorsInverted) { toMaterial(colors, colorsInverted) } + val materialTypography = remember(typography) { toMaterial(typography) } + val materialShapes = remember(shapes) { toMaterial(shapes) } + + MaterialTheme( + colors = materialColors, + typography = materialTypography, + shapes = materialShapes, + content = content + ) + } +} + +fun toMaterial(colors: IvyColors, colorsInverted: IvyColors): Colors { + return Colors( + primary = colors.primary, + primaryVariant = colors.primaryP1, + secondary = colors.primary, + secondaryVariant = colors.primaryP1, + background = colors.pure, + surface = colors.pure, + onSurface = colorsInverted.pure, + error = colors.red, + onPrimary = contrastColor(colors.primary), + onSecondary = contrastColor(colors.primary), + onBackground = colorsInverted.pure, + onError = contrastColor(colors.red), + isLight = colors.isLight + ) +} + +fun toMaterial(typography: IvyTypography): Typography { + return Typography( + h1 = typography.h1, + h2 = typography.h2, + body1 = typography.b1, + body2 = typography.b2, + caption = typography.c + ) +} + +fun toMaterial(shapes: IvyShapes): Shapes { + return Shapes( + large = shapes.fullyRounded, + medium = shapes.rounded, + small = shapes.squared + ) +} diff --git a/design-system/src/main/java/com/ivy/design/l0_system/IvyTypography.kt b/design-system/src/main/java/com/ivy/design/l0_system/IvyTypography.kt new file mode 100644 index 0000000..4f6628a --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l0_system/IvyTypography.kt @@ -0,0 +1,13 @@ +package com.ivy.design.l0_system + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.TextStyle + +@Immutable +data class IvyTypography( + val h1: TextStyle, + val h2: TextStyle, + val b1: TextStyle, + val b2: TextStyle, + val c: TextStyle, +) \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l0_system/TypographyExt.kt b/design-system/src/main/java/com/ivy/design/l0_system/TypographyExt.kt new file mode 100644 index 0000000..e2f0ead --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l0_system/TypographyExt.kt @@ -0,0 +1,21 @@ +package com.ivy.design.l0_system + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign + +fun TextStyle.colorAs(color: Color) = this.copy(color = color) + + +@Composable +fun TextStyle.style( + color: Color = UI.colorsInverted.pure, + fontWeight: FontWeight = FontWeight.Bold, + textAlign: TextAlign = TextAlign.Start +) = this.copy( + color = color, + fontWeight = fontWeight, + textAlign = textAlign +) \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l0_system/color/ColorUtil.kt b/design-system/src/main/java/com/ivy/design/l0_system/color/ColorUtil.kt new file mode 100644 index 0000000..1949a94 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l0_system/color/ColorUtil.kt @@ -0,0 +1,120 @@ +package com.ivy.design.l0_system.color + +import androidx.annotation.ColorInt +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.toArgb +import androidx.core.graphics.ColorUtils + +// region Contrast color +@Composable +fun rememberContrast(color: Color): Color = remember(color) { + contrastColor(color) +} + + +fun contrastColor(color: Color): Color = if (isDarkColor(color.toArgb())) White else Black +// endregion + +// region Dynamic Contrast +@Composable +fun rememberDynamicContrast(color: Color): Color = remember(color) { + color.dynamicContrast() +} + +private fun Color.dynamicContrast(): Color { + val pickedColor = this.toHSVSpec() + + return when { + pickedColor.s >= 0.5f && pickedColor.v >= 0.4f -> { + //Primary + if (isDarkColor(this)) { + lighten() + } else { + darken() + } + } + pickedColor.s <= 0.5f && pickedColor.v >= 0.8f -> { + //Light + darken() + } + pickedColor.s >= 0.1f && pickedColor.v <= 0.6f -> { + //Dark + lighten() + } + else -> { + if (isDarkColor(this)) { + lighten() + } else { + darken() + } + } + } +} + +private fun Color.lighten(): Color = this.hsv( + s = 0.3f, + v = 1f +) + +private fun Color.darken(): Color = this.hsv( + s = 0.6f, + v = 0.5f +) + +private fun Color.toHSVSpec(): HSVSpec { + val hsv = FloatArray(3) + val color: Int = this.toArgb() + android.graphics.Color.colorToHSV(color, hsv) + return HSVSpec(hsv[0], hsv[1], hsv[2]) +} + +data class HSVSpec( + val h: Float, + val s: Float, + val v: Float +) + +private fun Color.hsv( + h: Float? = null, + s: Float, + v: Float +): Color { + val hsv = FloatArray(3) + val color: Int = this.toArgb() + android.graphics.Color.colorToHSV(color, hsv) + + if (h != null) { + hsv[0] = h + } + + hsv[1] = s + hsv[2] = v + + return Color(android.graphics.Color.HSVToColor(hsv)) +} +// endregion + +private fun isDarkColor(color: Color): Boolean = isDarkColor(color.toArgb()) + +private fun isDarkColor(@ColorInt color: Int): Boolean = + ColorUtils.calculateLuminance(color) <= 0.5 + +// region Extensions +fun Int.toComposeColor() = Color(this) + +fun Color.asBrush(): Brush = SolidColor(this) +// endregion + +// region Hex <> Color +fun Color.toHex() = Integer.toHexString(this.toArgb()).drop(2).uppercase() + +fun fromHex(hex: String): Color? = try { + android.graphics.Color.parseColor("#$hex").toComposeColor() +} catch (ignored: Exception) { + null +} +// endregion \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l0_system/color/Colors.kt b/design-system/src/main/java/com/ivy/design/l0_system/color/Colors.kt new file mode 100644 index 0000000..062918c --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l0_system/color/Colors.kt @@ -0,0 +1,137 @@ +package com.ivy.design.l0_system.color + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import com.ivy.design.l0_system.UI + +// region Monochrome +val White = Color(0xFFFAFAFA) +val Black = Color(0xFF111114) +val MediumBlack = Color(0xFF2B2C2D) +val Gray = Color(0xFF939199) +val MediumWhite = Color(0xFFEFEEF0) +val Transparent = Color(0x00000000) +// endregion + +// region Primary +val Purple = Color(0xFF6B4DFF) +val Purple1 = Color(0xFFC34CFF) +val Purple2 = Color(0xFFFF4CFF) + +val Blue = Color(0xFF4CC3FF) +val Blue2 = Color(0xFF45E6E6) +val Blue3 = Color(0xFF457BE6) + +val Green = Color(0xFF14CC9E) +val Green2 = Color(0xFF45E67B) +val Green3 = Color(0xFF96E645) +val Green4 = Color(0xFFC7E62E) + +val Yellow = Color(0xFFF2E230) + +val Orange = Color(0xFFF29F30) +val Orange2 = Color(0xFFE67B45) +val Orange3 = Color(0xFFFFC34C) + +val Red = Color(0xFFFF4060) +val Red2 = Color(0xFFE62E2E) +val Red3 = Color(0xFFFF4CA6) +// endregion + +// region Light variants of primary +val IvyLight = Color(0xFFD5CCFF) +val Purple1Light = Color(0xFFEECCFF) +val Purple2Light = Color(0xFFFFBFFF) + +val BlueLight = Color(0xFFB3E6FF) +val Blue2Light = Color(0xFFB3FFFF) +val Blue3Light = Color(0xFFCCDDFF) + +val GreenLight = Color(0xFFAAF2E0) +val Green2Light = Color(0xFF99FFBB) +val Green3Light = Color(0xFFCCFF99) +val Green4Light = Color(0xFFEEFF99) + +val YellowLight = Color(0xFFFFF9B2) +val YellowP1Light = Color(0xFFFFF266) + +val OrangeLight = Color(0xFFFFDEB3) +val Orange2Light = Color(0xFFFFCCB3) +val Orange3Light = Color(0xFFFFDC99) + +val RedLight = Color(0xFFFFCCD5) +val Red2Light = Color(0xFFFFB3B3) +val Red3Light = Color(0xFFFFCCE6) +// endregion + +// region Dark variations of primary +val IvyDark = Color(0xFF352680) +val Purple1Dark = Color(0xFF622680) +val Purple2Dark = Color(0xFF802680) + +val BlueDark = Color(0xFF266280) +val Blue2Dark = Color(0xFF227373) +val Blue3Dark = Color(0xFF223D73) + +val GreenDark = Color(0xFF0A664F) +val Green2Dark = Color(0xFF22733D) +val Green3Dark = Color(0xFF66804D) +val Green4Dark = Color(0xFF637317) + +val YellowDark = Color(0xFF806A00) +val YellowP1Dark = Color(0xFFBFA730) + + +val OrangeDark = Color(0xFF734B17) +val Orange2Dark = Color(0xFF66371F) +val Orange3Dark = Color(0xFF806226) + +val RedDark = Color(0xFF801919) +val Red2Dark = Color(0xFF802030) +val Red3Dark = Color(0xFF802653) +// endregion + +// region Gradients +val GradientRed = Gradient(Red, Color(0xFFFF99AB)) +val GradientGreen = Gradient(Green, Color(0xFF49F2C8)) +val GradientOrange = Gradient(Orange, OrangeLight) +val GradientOrangeDark = Gradient(OrangeDark, Color(0xFFF2CD9E)) +val GradientOrangeRevert = Gradient(Color(0xFFF2CD9E), Orange) +val GradientPurple = Gradient(Purple, Color(0xFFAA99FF)) +val SunsetNight = Gradient(Red, Orange) + +@Immutable +data class Gradient( + val start: Color, + val end: Color +) { + companion object { + fun from(startColor: Int, endColor: Int? = null) = Gradient( + start = startColor.toComposeColor(), + end = (endColor ?: startColor).toComposeColor() + ) + + fun solid(color: Color) = Gradient(color, color) + + fun neutral(lightTheme: Boolean) = Gradient( + start = Gray, + end = if (lightTheme) Black else White + ) + + @Composable + fun black() = Gradient(UI.colors.neutral, UI.colorsInverted.pure) + } + + fun asHorizontalBrush() = Brush.horizontalGradient(colors = listOf(start, end)) + + fun asVerticalBrush() = Brush.verticalGradient(colors = listOf(start, end)) +} +// endregion + +@Composable +fun pureBlur() = UI.colors.pure.copy(alpha = 0.95f) + +@Composable +fun mediumBlur() = UI.colors.medium.copy(alpha = 0.95f) \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l0_system/color/IvyColors.kt b/design-system/src/main/java/com/ivy/design/l0_system/color/IvyColors.kt new file mode 100644 index 0000000..292abbd --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l0_system/color/IvyColors.kt @@ -0,0 +1,46 @@ +package com.ivy.design.l0_system.color + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +@Immutable +data class IvyColors( + // region Monochrome + val pure: Color, + val neutral: Color, + val medium: Color, + // endregion + + // region Dynamic + val primary: Color, + val primaryP1: Color, + val primaryP2: Color, + + val red: Color, + val redP1: Color, + val redP2: Color, + + val orange: Color, + val orangeP1: Color, + val orangeP2: Color, + + val yellow: Color, + val yellowP1: Color, + val yellowP2: Color, + + val green: Color, + val greenP1: Color, + val greenP2: Color, + + val blue: Color, + val blueP1: Color, + val blueP2: Color, + + val purple: Color, + val purpleP1: Color, + val purpleP2: Color, + + val isLight: Boolean, + + val transparent: Color = Transparent +) \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/ColumnRoot.kt b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/ColumnRoot.kt new file mode 100644 index 0000000..506da86 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/ColumnRoot.kt @@ -0,0 +1,32 @@ +package com.ivy.design.l1_buildingBlocks + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.ivy.design.util.thenIf + +@Composable +fun ColumnRoot( + modifier: Modifier = Modifier, + statusBarPadding: Boolean = true, + navigationBarsPadding: Boolean = true, + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + Content: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = modifier + .fillMaxSize() + .thenIf(statusBarPadding) { + statusBarsPadding() + } + .thenIf(navigationBarsPadding) { + navigationBarsPadding() + }, + horizontalAlignment = horizontalAlignment, + verticalArrangement = verticalArrangement + ) { + Content() + } +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/Dividers.kt b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/Dividers.kt new file mode 100644 index 0000000..5ecb854 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/Dividers.kt @@ -0,0 +1,201 @@ +package com.ivy.design.l1_buildingBlocks + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.thenWhen + +@Composable +fun DividerHor( + size: DividerSize = DividerSize.FillMax( + padding = 16.dp + ), + width: Dp = 1.dp, + color: Color = UI.colors.neutral, + shape: Shape = UI.shapes.fullyRounded +) { + Spacer( + modifier = Modifier + .thenWhen { + when (size) { + is DividerSize.FillMax -> { + fillMaxWidth() + .padding(horizontal = size.padding) + } + is DividerSize.Fixed -> { + this.width(size.size) + } + } + } + .height(width) + .background(color, shape) + ) +} + +@Composable +fun DividerVer( + size: DividerSize = DividerSize.FillMax( + padding = 16.dp + ), + width: Dp = 1.dp, + color: Color = UI.colors.neutral, + shape: Shape = UI.shapes.fullyRounded +) { + Spacer( + modifier = Modifier + .thenWhen { + when (size) { + is DividerSize.FillMax -> { + fillMaxHeight() + .padding(vertical = size.padding) + } + is DividerSize.Fixed -> { + this.height(size.size) + } + } + } + .width(width) + .background(color, shape) + ) +} + +@Composable +fun RowScope.DividerW( + weight: Float = 1f, + height: Dp = 1.dp, + color: Color = UI.colors.neutral, + shape: Shape = UI.shapes.fullyRounded +) { + Divider( + modifier = Modifier + .weight(weight) + .height(height), + color = color, + shape = shape + ) +} + +@Composable +fun ColumnScope.DividerW( + weight: Float = 1f, + width: Dp = 1.dp, + color: Color = UI.colors.neutral, + shape: Shape = UI.shapes.fullyRounded +) { + Divider( + modifier = Modifier + .weight(weight) + .width(width), + color = color, + shape = shape + ) +} + +@Composable +fun Divider( + modifier: Modifier = Modifier, + color: Color = UI.colors.neutral, + shape: Shape = UI.shapes.fullyRounded +) { + Spacer( + modifier = modifier + .background(color, shape) + ) +} + +@Immutable +sealed class DividerSize { + @Immutable + data class Fixed(val size: Dp) : DividerSize() + + @Immutable + data class FillMax(val padding: Dp) : DividerSize() +} + + +// region Previews +@Preview +@Composable +private fun PreviewHorizontalDivider_fillMax() { + ComponentPreview { + DividerHor( + size = DividerSize.FillMax( + padding = 16.dp + ) + ) + } +} + +@Preview +@Composable +private fun PreviewHorizontalDivider_fixed() { + ComponentPreview { + DividerHor( + size = DividerSize.Fixed( + size = 32.dp + ) + ) + } +} + +@Preview +@Composable +private fun PreviewVerticalDivider_fillMax() { + ComponentPreview { + DividerVer( + size = DividerSize.FillMax( + padding = 16.dp + ) + ) + } +} + +@Preview +@Composable +private fun PreviewVerticalDivider_fixed() { + ComponentPreview { + DividerVer( + size = DividerSize.Fixed( + size = 16.dp + ) + ) + } +} + +@Preview +@Composable +private fun PreviewDivider() { + ComponentPreview { + Row { + SpacerHor(16.dp) + Divider( + modifier = Modifier + .weight(1f) + .height(2.dp) + ) + SpacerHor(16.dp) + Divider( + modifier = Modifier + .weight(1f) + .height(2.dp) + ) + SpacerHor(16.dp) + Divider( + modifier = Modifier + .weight(1f) + .height(2.dp) + ) + SpacerHor(16.dp) + } + } +} +// endregion \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/HapticFeedback.kt b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/HapticFeedback.kt new file mode 100644 index 0000000..c691bc7 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/HapticFeedback.kt @@ -0,0 +1,44 @@ +package com.ivy.design.l1_buildingBlocks + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback + +@OptIn(ExperimentalFoundationApi::class) +fun Modifier.hapticClickable( + enabled: Boolean = true, + hapticFeedbackEnabled: Boolean = true, + onLongClick: (() -> Unit)? = null, + onClick: () -> Unit +): Modifier { + return if (hapticFeedbackEnabled) composed { + // with haptic feedback + val hapticFeedback = LocalHapticFeedback.current + + if (onLongClick != null) this.combinedClickable( + enabled = enabled, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onClick() + }, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onLongClick() + } + ) else this.clickable(enabled = enabled) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onClick() + } + } else { + // no haptic feedback + if (onLongClick != null) this.combinedClickable( + enabled = enabled, + onClick = onClick, + onLongClick = onLongClick, + ) else this.clickable(enabled = enabled, onClick = onClick) + } +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/Icon.kt b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/Icon.kt new file mode 100644 index 0000000..ef35365 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/Icon.kt @@ -0,0 +1,42 @@ +package com.ivy.design.l1_buildingBlocks + +import androidx.annotation.DrawableRes +import androidx.compose.material.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import com.ivy.design.R +import com.ivy.design.l0_system.UI +import com.ivy.design.util.ComponentPreview + +@Composable +fun IconRes( + @DrawableRes + icon: Int, + modifier: Modifier = Modifier, + tint: Color = UI.colorsInverted.pure, + contentDescription: String = "icon" +) { + Icon( + modifier = modifier, + painter = painterResource(id = icon), + contentDescription = contentDescription, + tint = tint + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + ComponentPreview { + IconRes( + icon = R.drawable.ic_ivy_logo, + tint = Color.Unspecified, + ) + } +} +// endregion \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/InputField.kt b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/InputField.kt new file mode 100644 index 0000000..77ff45c --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/InputField.kt @@ -0,0 +1,174 @@ +package com.ivy.design.l1_buildingBlocks + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActionScope +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.TextFieldDefaults +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.* +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.style +import com.ivy.design.util.ComponentPreview +import com.ivy.resources.R +import kotlinx.coroutines.delay + +@Composable +fun InputField( + initialValue: String, + placeholder: String, + singleLine: Boolean, + maxLines: Int, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + isError: Boolean = false, + @DrawableRes + iconLeft: Int? = null, + shape: Shape = UI.shapes.rounded, + focusedColor: Color = UI.colors.primary, + textStyle: TextStyle = UI.typo.b2.style(fontWeight = FontWeight.Bold), + keyboardType: KeyboardType = KeyboardType.Text, + keyboardCapitalization: KeyboardCapitalization = KeyboardCapitalization.None, + visualTransformation: VisualTransformation = VisualTransformation.None, + imeAction: ImeAction = ImeAction.Done, + onImeAction: KeyboardActionScope.(ImeAction) -> Unit = { + defaultKeyboardAction(it) + }, + onValueChange: (String) -> Unit, +) { + var textField by remember { + // move the cursor at the end of the text + val selection = TextRange(initialValue.length) + mutableStateOf(TextFieldValue(initialValue, selection)) + } + LaunchedEffect(initialValue) { + if (initialValue != textField.text && initialValue.isNotBlank()) { + delay(50) // fix race condition + textField = TextFieldValue( + initialValue, TextRange(initialValue.length) + ) + } + } + OutlinedTextField( + modifier = modifier, + value = textField, + onValueChange = { newValue -> + // new value different than the current one + if (newValue.text != textField.text) { + onValueChange(newValue.text) + } + textField = newValue + }, + shape = shape, + textStyle = textStyle, + leadingIcon = if (iconLeft != null) { + { + IconRes( + modifier = Modifier.padding(start = 4.dp), + icon = iconLeft, + ) + } + } else null, + placeholder = { + Text( + text = placeholder, + typo = textStyle, + color = UI.colors.neutral + ) + }, + enabled = enabled, + readOnly = readOnly, + isError = isError, + singleLine = singleLine, + maxLines = maxLines, + colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = UI.colorsInverted.pure, + cursorColor = UI.colorsInverted.pure, + backgroundColor = UI.colors.pure, + focusedBorderColor = focusedColor, + focusedLabelColor = focusedColor, + disabledBorderColor = UI.colors.neutral, + disabledLabelColor = UI.colors.neutral, + errorBorderColor = UI.colors.red, + errorLabelColor = UI.colors.red, + ), + keyboardOptions = KeyboardOptions( + capitalization = keyboardCapitalization, + autoCorrect = true, + keyboardType = keyboardType, + imeAction = imeAction, + ), + visualTransformation = visualTransformation, + keyboardActions = KeyboardActions( + onDone = { + onImeAction(ImeAction.Done) + }, + onGo = { + onImeAction(ImeAction.Go) + }, + onNext = { + onImeAction(ImeAction.Next) + }, + onPrevious = { + onImeAction(ImeAction.Previous) + }, + onSearch = { + onImeAction(ImeAction.Search) + }, + onSend = { + onImeAction(ImeAction.Send) + }, + ) + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview_Hint() { + ComponentPreview { + InputField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + initialValue = "", + iconLeft = R.drawable.round_search_24, + placeholder = "Placeholder", + singleLine = true, + maxLines = 1, + onValueChange = {} + ) + } +} + +@Preview +@Composable +private fun Preview_Text() { + ComponentPreview { + InputField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + iconLeft = R.drawable.round_search_24, + initialValue = "Input", + placeholder = "Placeholder", + singleLine = true, + maxLines = 1, + onValueChange = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/Shadow.kt b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/Shadow.kt new file mode 100644 index 0000000..ffd5cb0 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/Shadow.kt @@ -0,0 +1,64 @@ +package com.ivy.design.l1_buildingBlocks + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.util.ComponentPreview + +fun Modifier.glow(color: Color) = drawColoredShadow(color = color) + +private fun Modifier.drawColoredShadow( + color: Color, + alpha: Float = 0.15f, + borderRadius: Dp = 0.dp, + shadowRadius: Dp = 16.dp, + offsetX: Dp = 0.dp, + offsetY: Dp = 8.dp +) = this.drawBehind { + val transparentColor = android.graphics.Color.toArgb(color.copy(alpha = 0.0f).value.toLong()) + val shadowColor = android.graphics.Color.toArgb(color.copy(alpha = alpha).value.toLong()) + this.drawIntoCanvas { + val paint = Paint() + val frameworkPaint = paint.asFrameworkPaint() + frameworkPaint.color = transparentColor + frameworkPaint.setShadowLayer( + shadowRadius.toPx(), + offsetX.toPx(), + offsetY.toPx(), + shadowColor + ) + it.drawRoundRect( + 0f, + 0f, + this.size.width, + this.size.height, + borderRadius.toPx(), + borderRadius.toPx(), + paint + ) + } +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + ComponentPreview { + Shape( + modifier = Modifier.glow(UI.colors.green), + size = 32.dp, + shape = CircleShape, + color = UI.colors.blue + ) + } +} +// endregion \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/Shapes.kt b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/Shapes.kt new file mode 100644 index 0000000..d39b518 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/Shapes.kt @@ -0,0 +1,81 @@ +package com.ivy.design.l1_buildingBlocks + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.util.ComponentPreview + +@Composable +fun Shape( + size: Dp, + shape: Shape, + color: Color, + modifier: Modifier = Modifier, +) { + Spacer( + modifier = modifier + .size(size) + .background(color = color, shape = shape) + ) +} + +@Composable +fun ShapeOutlined( + size: Dp, + shape: Shape, + borderColor: Color, + modifier: Modifier = Modifier, + borderWidth: Dp = 1.dp, +) { + Spacer( + modifier = modifier + .size(size) + .border( + color = borderColor, + width = borderWidth, + shape = shape + ) + ) +} + +@Composable +fun Shape(modifier: Modifier = Modifier) { + Spacer(modifier) +} + + +// region Previews +@Preview +@Composable +private fun Preview_Circle() { + ComponentPreview { + Shape( + size = 32.dp, + shape = UI.shapes.circle, + color = UI.colors.primary + ) + } +} + +@Preview +@Composable +private fun PreviewOutlined() { + ComponentPreview { + ShapeOutlined( + size = 64.dp, + shape = UI.shapes.rounded, + borderWidth = 2.dp, + borderColor = UI.colors.neutral + ) + } +} +// endregion \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/Spacers.kt b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/Spacers.kt new file mode 100644 index 0000000..b9105ad --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/Spacers.kt @@ -0,0 +1,26 @@ +package com.ivy.design.l1_buildingBlocks + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp + +@Composable +fun SpacerVer(height: Dp) { + Spacer(Modifier.height(height)) +} + +@Composable +fun SpacerHor(width: Dp) { + Spacer(Modifier.width(width)) +} + +@Composable +fun RowScope.SpacerWeight(weight: Float) { + Spacer(Modifier.weight(weight)) +} + +@Composable +fun ColumnScope.SpacerWeight(weight: Float) { + Spacer(Modifier.weight(weight)) +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/Text.kt b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/Text.kt new file mode 100644 index 0000000..a17831b --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/Text.kt @@ -0,0 +1,286 @@ +package com.ivy.design.l1_buildingBlocks + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.style +import com.ivy.design.util.ComponentPreview + +// region Text typography +@Composable +fun H1( + text: String, + modifier: Modifier = Modifier, + fontWeight: FontWeight = FontWeight.Bold, + color: Color = UI.colorsInverted.pure, + textAlign: TextAlign = TextAlign.Start, + overflow: TextOverflow = TextOverflow.Ellipsis, + maxLines: Int = Int.MAX_VALUE, +) { + Text( + text = text, + typo = UI.typo.h1, + modifier = modifier, + fontWeight = fontWeight, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines + ) +} + +@Composable +fun H2( + text: String, + modifier: Modifier = Modifier, + fontWeight: FontWeight = FontWeight.Bold, + color: Color = UI.colorsInverted.pure, + textAlign: TextAlign = TextAlign.Start, + overflow: TextOverflow = TextOverflow.Ellipsis, + maxLines: Int = Int.MAX_VALUE, +) { + Text( + text = text, + typo = UI.typo.h2, + modifier = modifier, + fontWeight = fontWeight, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines + ) +} + +@Composable +fun B1( + text: String, + modifier: Modifier = Modifier, + fontWeight: FontWeight = FontWeight.Bold, + color: Color = UI.colorsInverted.pure, + textAlign: TextAlign = TextAlign.Start, + overflow: TextOverflow = TextOverflow.Ellipsis, + maxLines: Int = Int.MAX_VALUE, +) { + Text( + text = text, + typo = UI.typo.b1, + modifier = modifier, + fontWeight = fontWeight, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines + ) +} + +@Composable +fun B2( + text: String, + modifier: Modifier = Modifier, + fontWeight: FontWeight = FontWeight.Bold, + color: Color = UI.colorsInverted.pure, + textAlign: TextAlign = TextAlign.Start, + overflow: TextOverflow = TextOverflow.Ellipsis, + maxLines: Int = Int.MAX_VALUE, +) { + Text( + text = text, + typo = UI.typo.b2, + modifier = modifier, + fontWeight = fontWeight, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines + ) +} + +@Composable +fun Caption( + text: String, + modifier: Modifier = Modifier, + fontWeight: FontWeight = FontWeight.Bold, + color: Color = UI.colorsInverted.pure, + textAlign: TextAlign = TextAlign.Start, + overflow: TextOverflow = TextOverflow.Ellipsis, + maxLines: Int = Int.MAX_VALUE, +) { + Text( + text = text, + typo = UI.typo.c, + modifier = modifier, + fontWeight = fontWeight, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines + ) +} +// endregion + +// region Secondary typography +@Composable +fun H1Second( + text: String, + modifier: Modifier = Modifier, + fontWeight: FontWeight = FontWeight.Bold, + color: Color = UI.colorsInverted.pure, + textAlign: TextAlign = TextAlign.Start, + overflow: TextOverflow = TextOverflow.Ellipsis, + maxLines: Int = Int.MAX_VALUE, +) { + Text( + text = text, + typo = UI.typoSecond.h1, + modifier = modifier, + fontWeight = fontWeight, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines + ) +} + +@Composable +fun H2Second( + text: String, + modifier: Modifier = Modifier, + fontWeight: FontWeight = FontWeight.Bold, + color: Color = UI.colorsInverted.pure, + textAlign: TextAlign = TextAlign.Start, + overflow: TextOverflow = TextOverflow.Ellipsis, + maxLines: Int = Int.MAX_VALUE, +) { + Text( + text = text, + typo = UI.typoSecond.h2, + modifier = modifier, + fontWeight = fontWeight, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines + ) +} + +@Composable +fun B1Second( + text: String, + modifier: Modifier = Modifier, + fontWeight: FontWeight = FontWeight.Bold, + color: Color = UI.colorsInverted.pure, + textAlign: TextAlign = TextAlign.Start, + overflow: TextOverflow = TextOverflow.Ellipsis, + maxLines: Int = Int.MAX_VALUE, +) { + Text( + text = text, + typo = UI.typoSecond.b1, + modifier = modifier, + fontWeight = fontWeight, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines + ) +} + +@Composable +fun B2Second( + text: String, + modifier: Modifier = Modifier, + fontWeight: FontWeight = FontWeight.Bold, + color: Color = UI.colorsInverted.pure, + textAlign: TextAlign = TextAlign.Start, + overflow: TextOverflow = TextOverflow.Ellipsis, + maxLines: Int = Int.MAX_VALUE, +) { + Text( + text = text, + typo = UI.typoSecond.b2, + modifier = modifier, + fontWeight = fontWeight, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines + ) +} + +@Composable +fun CaptionSecond( + text: String, + modifier: Modifier = Modifier, + fontWeight: FontWeight = FontWeight.Bold, + color: Color = UI.colorsInverted.pure, + textAlign: TextAlign = TextAlign.Start, + overflow: TextOverflow = TextOverflow.Ellipsis, + maxLines: Int = Int.MAX_VALUE, +) { + Text( + text = text, + typo = UI.typoSecond.c, + modifier = modifier, + fontWeight = fontWeight, + color = color, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines + ) +} +// endregion + +@Composable +fun Text( + text: String, + typo: TextStyle, + modifier: Modifier = Modifier, + fontWeight: FontWeight = FontWeight.Bold, + color: Color = UI.colorsInverted.pure, + textAlign: TextAlign = TextAlign.Start, + overflow: TextOverflow = TextOverflow.Ellipsis, + maxLines: Int = Int.MAX_VALUE, +) { + Text( + modifier = modifier, + text = text, + style = typo.style( + fontWeight = fontWeight, + color = color, + textAlign = textAlign, + ), + overflow = overflow, + maxLines = maxLines, + ) +} + +@Preview +@Composable +private fun Preview() { + ComponentPreview { + Column { + H1("Heading 1") + H2("Heading 2") + B1("Body 1") + B2("Body 2") + Caption("Caption") + + SpacerVer(height = 8.dp) + + H1Second("1") + H2Second("2") + B1Second("3") + B2Second("4") + CaptionSecond("5") + } + } +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/data/Background.kt b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/data/Background.kt new file mode 100644 index 0000000..7c23bdb --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/data/Background.kt @@ -0,0 +1,169 @@ +package com.ivy.design.l1_buildingBlocks.data + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.color.asBrush +import com.ivy.design.util.paddingIvy +import com.ivy.design.util.thenWhen + + +// region constructor functions +fun solid( + shape: Shape, + color: Brush, + padding: IvyPadding? = null +) = Background.Solid( + color = color, + shape = shape, + padding = padding +) + +fun solid( + shape: Shape, + color: Color, + padding: IvyPadding? = null +) = Background.Solid( + color = color.asBrush(), + shape = shape, + padding = padding +) + +fun outlined( + shape: Shape, + color: Brush, + width: Dp = 1.dp, + padding: IvyPadding? = null +) = Background.Outlined( + color = color, + shape = shape, + width = width, + padding = padding +) + +fun outlined( + shape: Shape, + color: Color, + width: Dp = 1.dp, + padding: IvyPadding? = null +) = Background.Outlined( + color = color.asBrush(), + shape = shape, + width = width, + padding = padding +) + +fun solidWithBorder( + shape: Shape, + solid: Brush, + borderColor: Brush, + borderWidth: Dp = 1.dp, + padding: IvyPadding? = null, +) = Background.SolidWithBorder( + shape = shape, + solid = solid, + borderColor = borderColor, + borderWidth = borderWidth, + padding = padding +) + +fun solidWithBorder( + shape: Shape, + solid: Color, + borderColor: Color, + borderWidth: Dp = 1.dp, + padding: IvyPadding? = null, +) = Background.SolidWithBorder( + shape = shape, + solid = solid.asBrush(), + borderColor = borderColor.asBrush(), + borderWidth = borderWidth, + padding = padding +) + +fun none(): Background.None = Background.None +// endregion + +@Immutable +sealed interface Background { + @Immutable + data class Solid internal constructor( + val shape: Shape, + val color: Brush, + val padding: IvyPadding? + ) : Background + + @Immutable + data class Outlined internal constructor( + val shape: Shape, + val color: Brush, + val width: Dp, + val padding: IvyPadding? + ) : Background + + @Immutable + data class SolidWithBorder internal constructor( + val shape: Shape, + val solid: Brush, + val borderColor: Brush, + val borderWidth: Dp, + val padding: IvyPadding?, + ) : Background + + @Immutable + object None : Background +} + +fun Modifier.applyBackground(background: Background): Modifier { + return thenWhen { + when (background) { + is Background.Solid -> { + background( + brush = background.color, + shape = background.shape + ).paddingIvy(background.padding) + } + is Background.Outlined -> { + border( + brush = background.color, + width = background.width, + shape = background.shape + ).paddingIvy(background.padding) + } + is Background.SolidWithBorder -> { + background(brush = background.solid, shape = background.shape) + .border( + brush = background.borderColor, + width = background.borderWidth, + shape = background.shape + ) + .paddingIvy(background.padding) + } + is Background.None -> null + } + } +} + +fun Modifier.clipBackground(background: Background): Modifier { + return thenWhen { + when (background) { + is Background.Solid -> { + clip(background.shape) + } + is Background.Outlined -> { + clip(background.shape) + } + is Background.SolidWithBorder -> { + clip(background.shape) + } + is Background.None -> null + } + } +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/data/IvyPadding.kt b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/data/IvyPadding.kt new file mode 100644 index 0000000..3534660 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l1_buildingBlocks/data/IvyPadding.kt @@ -0,0 +1,12 @@ +package com.ivy.design.l1_buildingBlocks.data + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.unit.Dp + +@Immutable +data class IvyPadding( + val top: Dp?, + val bottom: Dp?, + val start: Dp?, + val end: Dp? +) \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l2_components/Checkbox.kt b/design-system/src/main/java/com/ivy/design/l2_components/Checkbox.kt new file mode 100644 index 0000000..cd90ed6 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l2_components/Checkbox.kt @@ -0,0 +1,91 @@ +package com.ivy.design.l2_components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.R +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.style +import com.ivy.design.l1_buildingBlocks.IconRes +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.clickableNoIndication + +@Composable +fun Checkbox( + modifier: Modifier = Modifier, + checked: Boolean, + contentDescription: String = "checkbox", + onCheckedChange: (checked: Boolean) -> Unit +) { + IconRes( + modifier = modifier + .size(48.dp) + .clip(CircleShape) + .clickable(onClick = { + onCheckedChange(!checked) + }) + .padding(all = 12.dp), + icon = if (checked) R.drawable.ic_checkbox_checked else R.drawable.ic_checkbox_unchecked, + contentDescription = contentDescription, + tint = if (checked) Color.Unspecified else UI.colors.neutral + ) +} + +@Composable +fun CheckboxWithText( + modifier: Modifier = Modifier, + checked: Boolean, + text: String, + textStyle: TextStyle = UI.typo.b2.style( + color = UI.colorsInverted.pure, + fontWeight = FontWeight.SemiBold + ), + onCheckedChange: (checked: Boolean) -> Unit +) { + Row( + modifier = modifier + .clickableNoIndication { + onCheckedChange(!checked) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange + ) + + SpacerHor(width = 4.dp) + + Text( + modifier = Modifier, + text = text, + style = textStyle + ) + } +} + +@Preview +@Composable +private fun PreviewIvyCheckboxWithText() { + ComponentPreview { + CheckboxWithText( + text = "Default category", + checked = false, + ) { + + } + } +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l2_components/Switch.kt b/design-system/src/main/java/com/ivy/design/l2_components/Switch.kt new file mode 100644 index 0000000..41b6de4 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l2_components/Switch.kt @@ -0,0 +1,97 @@ +package com.ivy.design.l2_components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +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.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerWeight +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.springBounce + +@Composable +fun Switch( + enabled: Boolean, + modifier: Modifier = Modifier, + enabledColor: Color = UI.colors.primary, + disabledColor: Color = UI.colors.neutral, + animationColor: AnimationSpec = springBounce(), + animationMove: AnimationSpec = springBounce(), + onEnabledChange: (checked: Boolean) -> Unit, +) { + val color by animateColorAsState( + targetValue = if (enabled) enabledColor else disabledColor, + animationSpec = animationColor + ) + + Row( + modifier = modifier + .width(48.dp) + .clip(UI.shapes.fullyRounded) + .border(2.dp, color, UI.shapes.fullyRounded) + .clickable { + onEnabledChange(!enabled) + } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val weightStart by animateFloatAsState( + targetValue = if (enabled) 1f else 0f, + animationSpec = animationMove + ) + + SpacerHor(width = 4.dp) + + if (weightStart > 0) { + SpacerWeight(weight = weightStart) + } + + //Circle + Spacer( + modifier = Modifier + .size(16.dp) + .background(color, CircleShape) + ) + + val weightEnd = 1f - weightStart + if (weightEnd > 0) { + SpacerWeight(weight = weightEnd) + } + + SpacerHor(width = 4.dp) + } +} + +// region Preview +@Preview +@Composable +private fun Preview_Disabled() { + ComponentPreview { + var enabled by remember { mutableStateOf(false) } + + Switch(enabled = enabled) { enabled = it } + } +} + +@Preview +@Composable +private fun Preview_Enabled() { + ComponentPreview { + var enabled by remember { mutableStateOf(true) } + + Switch(enabled = enabled) { enabled = it } + } +} +// endregion \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l2_components/SwitchRow.kt b/design-system/src/main/java/com/ivy/design/l2_components/SwitchRow.kt new file mode 100644 index 0000000..f1f7f7e --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l2_components/SwitchRow.kt @@ -0,0 +1,59 @@ +package com.ivy.design.l2_components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.util.ComponentPreview + +@Composable +fun SwitchRow( + enabled: Boolean, + text: String, + modifier: Modifier = Modifier, + onValueChange: (Boolean) -> Unit +) { + Row( + modifier = modifier + .clip(UI.shapes.rounded) + .defaultMinSize(minHeight = 48.dp) + .clickable { + onValueChange(!enabled) + } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + B2( + modifier = Modifier + .weight(1f) + .padding(end = 12.dp), + text = text + ) + Switch(enabled = enabled, onEnabledChange = onValueChange) + } +} + + +@Preview +@Composable +private fun Preview() { + ComponentPreview { + SwitchRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + enabled = true, + text = "Switch Row", + onValueChange = {} + ) + } +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l2_components/button/Btn.kt b/design-system/src/main/java/com/ivy/design/l2_components/button/Btn.kt new file mode 100644 index 0000000..4cfe7d6 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l2_components/button/Btn.kt @@ -0,0 +1,6 @@ +package com.ivy.design.l2_components.button + +import androidx.compose.runtime.Immutable + +@Immutable +object Btn \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l2_components/button/Button.kt b/design-system/src/main/java/com/ivy/design/l2_components/button/Button.kt new file mode 100644 index 0000000..e7a7c51 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l2_components/button/Button.kt @@ -0,0 +1,110 @@ +package com.ivy.design.l2_components.button + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.White +import com.ivy.design.l0_system.colorAs +import com.ivy.design.l0_system.style +import com.ivy.design.l1_buildingBlocks.data.* +import com.ivy.design.l1_buildingBlocks.hapticClickable +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.padding + +@Suppress("unused") +@Composable +fun Btn.Text( + text: String, + modifier: Modifier = Modifier, + background: Background = solid( + color = UI.colors.primary, + shape = UI.shapes.fullyRounded, + padding = padding( + horizontal = 24.dp, + vertical = 12.dp + ) + ), + textStyle: TextStyle = UI.typo.b2.style( + color = White, + textAlign = TextAlign.Center + ), + hapticFeedback: Boolean = false, + onClick: () -> Unit +) { + Text( + modifier = modifier + .clipBackground(background) + .hapticClickable(hapticFeedbackEnabled = hapticFeedback, onClick = onClick) + .applyBackground(background), + text = text, + style = textStyle + ) +} + +// region Previews +@Preview +@Composable +private fun Preview_Solid() { + ComponentPreview { + Btn.Text( + text = "Okay", + background = solid( + color = UI.colors.primary, + shape = UI.shapes.fullyRounded, + padding = padding( + horizontal = 24.dp, + vertical = 12.dp + ) + ), + textStyle = UI.typo.b1.colorAs(White) + ) { + + } + } +} + +@Preview +@Composable +private fun Preview_Outlined() { + ComponentPreview { + Btn.Text( + text = "Continue", + background = outlined( + color = UI.colorsInverted.pure, + width = 1.dp, + shape = UI.shapes.fullyRounded, + padding = padding( + horizontal = 24.dp, + vertical = 12.dp + ) + ), + textStyle = UI.typo.b1.colorAs(UI.colorsInverted.pure) + ) { + + } + } +} + +@Preview +@Composable +private fun Preview_FillMaxWidth() { + ComponentPreview { + Btn.Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = "Add task" + ) { + + } + } +} +// endregion + diff --git a/design-system/src/main/java/com/ivy/design/l2_components/button/ButtonTextIcon.kt b/design-system/src/main/java/com/ivy/design/l2_components/button/ButtonTextIcon.kt new file mode 100644 index 0000000..7645940 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l2_components/button/ButtonTextIcon.kt @@ -0,0 +1,157 @@ +package com.ivy.design.l2_components.button + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.ivy.design.R +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.Transparent +import com.ivy.design.l0_system.color.White +import com.ivy.design.l0_system.style +import com.ivy.design.l1_buildingBlocks.IconRes +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.data.Background +import com.ivy.design.l1_buildingBlocks.data.applyBackground +import com.ivy.design.l1_buildingBlocks.data.clipBackground +import com.ivy.design.l1_buildingBlocks.data.solid +import com.ivy.design.l1_buildingBlocks.hapticClickable +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.padding + +@Suppress("unused") +@Composable +fun Btn.TextIcon( + text: String, + modifier: Modifier = Modifier, + mode: Mode = Mode.WrapContent, + background: Background = solid( + color = UI.colors.primary, + shape = UI.shapes.fullyRounded, + padding = padding( + horizontal = 24.dp, + vertical = 12.dp + ) + ), + textStyle: TextStyle = UI.typo.b1.style( + color = White, + textAlign = TextAlign.Center + ), + @DrawableRes iconLeft: Int? = null, + @DrawableRes iconRight: Int? = null, + iconTint: Color = White, + iconPadding: Dp = 12.dp, + hapticFeedback: Boolean = false, + onClick: () -> Unit +) { + Row( + modifier = modifier + .clipBackground(background) + .hapticClickable(hapticFeedbackEnabled = hapticFeedback, onClick = onClick) + .applyBackground(background), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + val icon = iconLeft ?: iconRight ?: error("If you don't add an icon, use Btn.Text()") + + if (mode == Mode.FillMaxWidth || iconLeft != null) { + // for FillMaxWidth add an invisible icon so the text looks centered + IconRes( + icon = icon, + tint = if (iconLeft != null) iconTint else Transparent + ) + SpacerHor(width = iconPadding) + } + + Text( + text = text, + style = textStyle + ) + + if (mode == Mode.FillMaxWidth || iconRight != null) { + // for FillMaxWidth add an invisible icon so the text looks centered + SpacerHor(width = iconPadding) + IconRes( + icon = icon, + tint = if (iconRight != null) iconTint else Transparent + ) + } + } +} + +enum class Mode { + WrapContent, FillMaxWidth +} + +@Preview +@Composable +private fun Preview_IconLeft_Wrap() { + ComponentPreview { + Btn.TextIcon( + text = "Button", + mode = Mode.WrapContent, + iconLeft = R.drawable.ic_vue_crypto_icon + ) { + + } + } +} + +@Preview +@Composable +private fun Preview_IconRight_Wrap() { + ComponentPreview { + Btn.TextIcon( + text = "Button", + mode = Mode.WrapContent, + iconRight = R.drawable.ic_vue_crypto_icon + ) { + + } + } +} + +@Preview +@Composable +private fun Preview_IconLeft_FillMax() { + ComponentPreview { + Btn.TextIcon( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + text = "Button", + mode = Mode.FillMaxWidth, + iconLeft = R.drawable.ic_vue_crypto_icon + ) { + + } + } +} + +@Preview +@Composable +private fun Preview_IconRight_FillMax() { + ComponentPreview { + Btn.TextIcon( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + text = "Button", + mode = Mode.FillMaxWidth, + iconRight = R.drawable.ic_vue_crypto_icon + ) { + + } + } +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l2_components/button/IconButton.kt b/design-system/src/main/java/com/ivy/design/l2_components/button/IconButton.kt new file mode 100644 index 0000000..0459520 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l2_components/button/IconButton.kt @@ -0,0 +1,63 @@ +package com.ivy.design.l2_components.button + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.R +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.White +import com.ivy.design.l1_buildingBlocks.IconRes +import com.ivy.design.l1_buildingBlocks.data.Background +import com.ivy.design.l1_buildingBlocks.data.applyBackground +import com.ivy.design.l1_buildingBlocks.data.clipBackground +import com.ivy.design.l1_buildingBlocks.data.solid +import com.ivy.design.l1_buildingBlocks.hapticClickable +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.padding + +@Suppress("unused") +@Composable +fun Btn.Icon( + @DrawableRes icon: Int, + modifier: Modifier = Modifier, + iconTint: Color = White, + background: Background = solid( + color = UI.colors.primary, + shape = CircleShape, + padding = padding(all = 8.dp) + ), + contentDescription: String = "icon button", + hapticFeedback: Boolean = false, + onClick: () -> Unit +) { + IconRes( + modifier = modifier + .clipBackground(background) + .hapticClickable(hapticFeedbackEnabled = hapticFeedback, onClick = onClick) + .applyBackground(background), + icon = icon, + tint = iconTint, + contentDescription = contentDescription, + ) +} + +@Preview +@Composable +private fun Preview() { + ComponentPreview { + Btn.Icon( + icon = R.drawable.ic_popup_add, + modifier = Modifier.size(48.dp), + background = solid( + CircleShape, UI.colors.primary, padding(all = 12.dp) + ) + ) { + + } + } +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l2_components/input/InputFieldType.kt b/design-system/src/main/java/com/ivy/design/l2_components/input/InputFieldType.kt new file mode 100644 index 0000000..b28f834 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l2_components/input/InputFieldType.kt @@ -0,0 +1,6 @@ +package com.ivy.design.l2_components.input + +sealed interface InputFieldType { + object SingleLine : InputFieldType + data class Multiline(val maxLines: Int = Int.MAX_VALUE) : InputFieldType +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l2_components/input/InputFieldTypography.kt b/design-system/src/main/java/com/ivy/design/l2_components/input/InputFieldTypography.kt new file mode 100644 index 0000000..5d83758 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l2_components/input/InputFieldTypography.kt @@ -0,0 +1,5 @@ +package com.ivy.design.l2_components.input + +enum class InputFieldTypography { + Primary, Secondary +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l2_components/input/IvyInputField.kt b/design-system/src/main/java/com/ivy/design/l2_components/input/IvyInputField.kt new file mode 100644 index 0000000..69c3dd0 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l2_components/input/IvyInputField.kt @@ -0,0 +1,90 @@ +package com.ivy.design.l2_components.input + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActionScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.style +import com.ivy.design.l1_buildingBlocks.InputField +import com.ivy.design.l2_components.input.InputFieldType.Multiline +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.button.toColor +import com.ivy.design.util.ComponentPreview + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun IvyInputField( + type: InputFieldType, + initialValue: String, + placeholder: String, + modifier: Modifier = Modifier, + isError: Boolean = false, + @DrawableRes + iconLeft: Int? = null, + shape: Shape = UI.shapes.rounded, + feeling: Feeling = Feeling.Positive, + typography: InputFieldTypography = InputFieldTypography.Primary, + keyboardCapitalization: KeyboardCapitalization = KeyboardCapitalization.None, + imeAction: ImeAction = if (type is Multiline) ImeAction.Default else ImeAction.Done, + onImeAction: (KeyboardActionScope.(ImeAction) -> Unit)? = null, + onValueChange: (String) -> Unit, +) { + val keyboardController = LocalSoftwareKeyboardController.current + InputField( + modifier = modifier, + initialValue = initialValue, + placeholder = placeholder, + isError = isError, + iconLeft = iconLeft, + shape = shape, + focusedColor = feeling.toColor(), + textStyle = when (typography) { + InputFieldTypography.Primary -> UI.typo.b2.style(fontWeight = FontWeight.Bold) + InputFieldTypography.Secondary -> UI.typoSecond.b2.style(fontWeight = FontWeight.Bold) + }, + singleLine = when (type) { + is Multiline -> false + InputFieldType.SingleLine -> true + }, + maxLines = when (type) { + is Multiline -> type.maxLines + InputFieldType.SingleLine -> Int.MAX_VALUE + }, + keyboardCapitalization = keyboardCapitalization, + imeAction = imeAction, + onImeAction = { + onImeAction?.invoke(this, it) ?: keyboardController?.hide() + }, + onValueChange = onValueChange + ) +} + + +// region Previews +@Preview +@Composable +private fun Preview() { + ComponentPreview { + IvyInputField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + type = InputFieldType.SingleLine, + initialValue = "Input", + placeholder = "Placeholder", + onValueChange = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l2_components/modal/Modal.kt b/design-system/src/main/java/com/ivy/design/l2_components/modal/Modal.kt new file mode 100644 index 0000000..258e3be --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l2_components/modal/Modal.kt @@ -0,0 +1,306 @@ +package com.ivy.design.l2_components.modal + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.* +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.mediumBlur +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l1_buildingBlocks.SpacerWeight +import com.ivy.design.l2_components.button.Btn +import com.ivy.design.l2_components.button.Text +import com.ivy.design.l2_components.modal.components.Body +import com.ivy.design.l2_components.modal.components.Positive +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.scope.ModalActionsScope +import com.ivy.design.l2_components.modal.scope.ModalActionsScopeImpl +import com.ivy.design.l2_components.modal.scope.ModalScope +import com.ivy.design.l2_components.modal.scope.ModalScopeImpl +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.* +import com.ivy.resources.R + +var openModals = 0 + +// region Ivy Modal +@Immutable +data class IvyModal( + val visibilityState: MutableState = mutableStateOf(false) +) { + fun hide() { + visibilityState.value = false + openModals-- + } + + fun show() { + visibilityState.value = true + openModals++ + } +} + +@Composable +fun rememberIvyModal(): IvyModal = remember { IvyModal() } +// endregion + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun BoxScope.Modal( + modal: IvyModal, + actions: @Composable ModalActionsScope.() -> Unit, + contentModifier: Modifier = Modifier, + keyboardShiftsContent: Boolean = true, + level: Int = 1, + content: @Composable ModalScope.() -> Unit +) { + val visible by modal.visibilityState + + AnimatedVisibility( + modifier = Modifier + .fillMaxSize() + .zIndex(1_000f * level), + visible = visible, + enter = fadeIn(), + exit = fadeOut() + ) { + val keyboardController = LocalSoftwareKeyboardController.current + Spacer( + modifier = Modifier + .fillMaxSize() + .background(mediumBlur()) + .testTag("modal_outside_blur") + .clickable( + onClick = { + keyboardController?.hide() + modal.hide() + }, + enabled = visible + ) + ) + } + + AnimatedVisibility( + modifier = Modifier + .align(Alignment.BottomCenter) + .zIndex(1_100f * level), + visible = visible, + enter = slideInVertically( + initialOffsetY = { fullHeight: Int -> fullHeight } + ), + exit = slideOutVertically( + targetOffsetY = { fullHeight: Int -> fullHeight } + ) + ) { + val systemBottomPadding = systemPaddingBottom() + val keyboardShown by keyboardShownState() + val keyboardShownInset = keyboardPadding() + val paddingBottom = if (keyboardShiftsContent) { + animateDpAsState( + targetValue = if (keyboardShown) + keyboardShownInset else systemBottomPadding, + ).value + } else systemBottomPadding + + Column( + modifier = contentModifier + .fillMaxWidth() + .statusBarsPadding() + .padding(top = 24.dp) // 24 dp from the status bar (top) + .background(UI.colors.pure, UI.shapes.roundedTop) + .clip(UI.shapes.roundedTop) + .consumeClicks() // don't close the modal when clicking on the empty space inside + .padding(bottom = paddingBottom) + ) { + BackHandler(enabled = modal.visibilityState.value) { + modal.hide() + } + + val modalScope = remember { ModalScopeImpl(this) } + with(modalScope) { + content() + } + + val keyboardController = LocalSoftwareKeyboardController.current + ModalActionsRow( + Actions = actions, + onClose = { + keyboardController?.hide() + modal.hide() + }, + ) + SpacerVer(height = 12.dp) + } + } +} + +@Composable +private fun ModalActionsRow( + Actions: @Composable ModalActionsScope.() -> Unit, + modifier: Modifier = Modifier, + onClose: () -> Unit, +) { + RowWithLine( + // don't add horizontal padding because it'll break the line + modifier = modifier.padding(top = 4.dp), + ) { + SpacerHor(width = 16.dp) + CloseButton( + modifier = Modifier.testTag("modal_close_button"), + onClick = onClose + ) + SpacerWeight(weight = 1f) + val actionsScope = remember { ModalActionsScopeImpl(this) } + with(actionsScope) { + Actions() + } + SpacerHor(width = 16.dp) + } + +} + +@Composable +private fun RowWithLine( + modifier: Modifier = Modifier, + lineColor: Color = UI.colors.medium, + Content: @Composable RowScope.() -> Unit +) { + Row( + modifier = modifier + .fillMaxWidth() + .drawBehind { + val height = this.size.height + val width = this.size.width + + drawLine( + color = lineColor, + strokeWidth = 2.dp.toPx(), + start = Offset(x = 0f, y = height / 2), + end = Offset(x = width, y = height / 2) + ) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Content() + } +} + +@Composable +fun CloseButton( + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + IvyButton( + modifier = modifier, + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = Feeling.Disabled, + text = null, + icon = R.drawable.ic_round_close_24, + onClick = onClick + ) +} + +// region Previews +@Preview +@Composable +private fun Preview_FullScreen() { + IvyPreview { + val modal = remember { IvyModal() } + if (isInPreview()) { + modal.show() + } + + Btn.Text(text = "Show modal") { + modal.show() + } + + Modal( + modal = modal, + actions = { + Positive(text = "Okay") { + modal.hide() + } + } + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Red) + ) + } + } +} + +@Preview +@Composable +private fun Preview_Partial() { + IvyPreview { + val modal = remember { IvyModal() } + val modal2 = remember { IvyModal() } + if (isInPreview()) { + modal.show() + } + + Btn.Text(text = "Show modal") { + modal.show() + } + + Modal( + modal = modal, + actions = { + IvyButton( + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = Feeling.Disabled, + text = null, + icon = R.drawable.ic_round_calculate_24 + ) { + modal2.show() + } + SpacerHor(width = 12.dp) + Positive(text = "Got it") { + modal.hide() + } + } + ) { + Title(text = "Title") + SpacerVer(height = 24.dp) + Body(text = "This is a test modal!") + SpacerVer(height = 48.dp) + } + + Modal( + modal = modal2, + actions = { + Positive(text = "Calculate", icon = R.drawable.ic_round_calculate_24) { + + } + } + ) { + Title(text = "Calculate") + SpacerVer(height = 24.dp) + Body(text = "Do you calculations here...") + SpacerVer(height = 48.dp) + } + } +} +// endregion \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l2_components/modal/ModalPreviewUtil.kt b/design-system/src/main/java/com/ivy/design/l2_components/modal/ModalPreviewUtil.kt new file mode 100644 index 0000000..6c348be --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l2_components/modal/ModalPreviewUtil.kt @@ -0,0 +1,6 @@ +package com.ivy.design.l2_components.modal + +import androidx.compose.runtime.Composable + +@Composable +fun previewModal(): IvyModal = rememberIvyModal().apply { show() } \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l2_components/modal/components/ModalActions.kt b/design-system/src/main/java/com/ivy/design/l2_components/modal/components/ModalActions.kt new file mode 100644 index 0000000..7a0c9d1 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l2_components/modal/components/ModalActions.kt @@ -0,0 +1,195 @@ +package com.ivy.design.l2_components.modal.components + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.R +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.scope.ModalActionsScope +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview + +@Suppress("unused") +@Composable +fun ModalActionsScope.DynamicSave( + item: Any?, + hapticFeedback: Boolean = false, + onClick: () -> Unit +) { + IvyButton( + size = ButtonSize.Small, + visibility = Visibility.Focused, + feeling = Feeling.Positive, + text = if (item != null) "Save" else "Add", + icon = if (item != null) R.drawable.ic_round_check_24 else R.drawable.ic_round_add_24, + hapticFeedback = hapticFeedback, + onClick = onClick + ) +} + +@Suppress("unused") +@Composable +fun ModalActionsScope.Set( + hapticFeedback: Boolean = false, + onClick: () -> Unit +) { + Positive( + text = stringResource(R.string.set), + icon = R.drawable.ic_round_check_24, + visibility = Visibility.Focused, + hapticFeedback = hapticFeedback, + onClick = onClick + ) +} + +@Suppress("unused") +@Composable +fun ModalActionsScope.Choose( + hapticFeedback: Boolean = false, + onClick: () -> Unit +) { + Positive( + text = "Choose", + icon = R.drawable.ic_round_check_24, + visibility = Visibility.Focused, + hapticFeedback = hapticFeedback, + onClick = onClick + ) +} + +@Suppress("unused") +@Composable +fun ModalActionsScope.Done( + hapticFeedback: Boolean = false, + onClick: () -> Unit +) { + Positive( + text = "Done", + icon = R.drawable.ic_round_check_24, + visibility = Visibility.Focused, + hapticFeedback = hapticFeedback, + onClick = onClick + ) +} + + +@Suppress("unused") +@Composable +fun ModalActionsScope.Positive( + text: String?, + @DrawableRes + icon: Int? = null, + visibility: Visibility = Visibility.Focused, + feeling: Feeling = Feeling.Positive, + hapticFeedback: Boolean = false, + onClick: () -> Unit +) { + IvyButton( + size = ButtonSize.Small, + visibility = visibility, + feeling = feeling, + text = text, + icon = icon, + hapticFeedback = hapticFeedback, + onClick = onClick, + ) +} + +@Suppress("unused") +@Composable +fun ModalActionsScope.Negative( + text: String, + @DrawableRes + icon: Int? = null, + visibility: Visibility = Visibility.Focused, + hapticFeedback: Boolean = false, + onClick: () -> Unit +) { + IvyButton( + size = ButtonSize.Small, + visibility = visibility, + feeling = Feeling.Negative, + text = text, + icon = icon, + hapticFeedback = hapticFeedback, + onClick = onClick, + ) +} + +@Suppress("unused") +@Composable +fun ModalActionsScope.Secondary( + text: String?, + @DrawableRes + icon: Int? = null, + feeling: Feeling = Feeling.Positive, + hapticFeedback: Boolean = false, + onClick: () -> Unit +) { + IvyButton( + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = feeling, + text = text, + icon = icon, + hapticFeedback = hapticFeedback, + onClick = onClick, + ) +} + +// region Previews +@Preview +@Composable +private fun Preview_DynamicSave_Add() { + IvyPreview { + val modal = remember { IvyModal() } + modal.show() + Modal( + modal = modal, + actions = { + DynamicSave(item = null) {} + } + ) {} + } +} + +@Preview +@Composable +private fun Preview_DynamicSave_Edit() { + IvyPreview { + val modal = remember { IvyModal() } + modal.show() + Modal( + modal = modal, + actions = { + DynamicSave(item = "Test") {} + } + ) {} + } +} + +@Preview +@Composable +private fun Preview_PositiveNegative() { + IvyPreview { + val modal = remember { IvyModal() } + modal.show() + Modal( + modal = modal, + actions = { + Negative(text = "No", visibility = Visibility.High) {} + SpacerHor(width = 12.dp) + Positive(text = "Yes") {} + } + ) {} + } +} +// endregion \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l2_components/modal/components/ModalBody.kt b/design-system/src/main/java/com/ivy/design/l2_components/modal/components/ModalBody.kt new file mode 100644 index 0000000..206efce --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l2_components/modal/components/ModalBody.kt @@ -0,0 +1,19 @@ +package com.ivy.design.l2_components.modal.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l2_components.modal.scope.ModalScope + +@Suppress("unused") +@Composable +fun ModalScope.Body(text: String) { + B2( + text = text, + modifier = Modifier.padding(horizontal = 32.dp), + fontWeight = FontWeight.Medium, + ) +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l2_components/modal/components/ModalSearch.kt b/design-system/src/main/java/com/ivy/design/l2_components/modal/components/ModalSearch.kt new file mode 100644 index 0000000..4e0f3d4 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l2_components/modal/components/ModalSearch.kt @@ -0,0 +1,155 @@ +package com.ivy.design.l2_components.modal.components + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.focus.focusRequester +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.l2_components.input.InputFieldType +import com.ivy.design.l2_components.input.IvyInputField +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l2_components.modal.scope.ModalActionsScope +import com.ivy.design.l2_components.modal.scope.ModalScope +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.util.IvyPreview +import com.ivy.resources.R + + +@Composable +fun ModalScope.Search( + searchBarVisible: Boolean, + initialSearchQuery: String, + searchHint: String, + resetSearch: () -> Unit, + onSearch: (String) -> Unit, + overlay: (@Composable BoxScope.() -> Unit)? = null, + content: LazyListScope.() -> Unit, +) { + Box(modifier = Modifier.weight(1f)) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + content = content + ) + SearchBar( + visible = searchBarVisible, + initialQuery = initialSearchQuery, + hint = searchHint, + resetSearch = resetSearch, + onSearch = onSearch + ) + overlay?.invoke(this) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun SearchBar( + visible: Boolean, + initialQuery: String, + hint: String, + resetSearch: () -> Unit, + onSearch: (String) -> Unit, +) { + // the FocusRequester must be remembered outside AnimatedVisibility + // otherwise it crashes for no reason + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + AnimatedVisibility( + modifier = Modifier + .fillMaxWidth() + .background(UI.colors.pure) + .padding(top = 16.dp, bottom = 8.dp), + visible = visible, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + IvyInputField( + modifier = Modifier + .focusRequester(focusRequester) + .fillMaxWidth() + .padding(horizontal = 16.dp), + type = InputFieldType.SingleLine, + initialValue = initialQuery, + placeholder = hint, + iconLeft = R.drawable.round_search_24, + imeAction = ImeAction.Search, + onImeAction = { + keyboardController?.hide() + focusRequester.freeFocus() + }, + onValueChange = { onSearch(it) }, + ) + + LaunchedEffect(visible) { + if (visible) { + focusRequester.requestFocus() + keyboardController?.show() + } + } + BackHandler(enabled = visible) { + resetSearch() + } + } +} + +@Composable +fun ModalActionsScope.SearchButton( + searchBarVisible: Boolean, + onClick: () -> Unit, +) { + Secondary( + text = null, + icon = if (searchBarVisible) + R.drawable.round_search_off_24 else R.drawable.round_search_24, + feeling = if (searchBarVisible) Feeling.Negative else Feeling.Positive, + onClick = onClick, + ) +} + + +// region Previews +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + Modal( + modal = modal, + actions = { + SearchButton( + searchBarVisible = true, + ) { + + } + } + ) { + Search( + searchBarVisible = true, + initialSearchQuery = "", + searchHint = "Search hint", + resetSearch = { }, + onSearch = {} + ) { + item { + Title("Modal title") + } + } + } + } +} +// endregion \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l2_components/modal/components/ModalTitle.kt b/design-system/src/main/java/com/ivy/design/l2_components/modal/components/ModalTitle.kt new file mode 100644 index 0000000..b1a4887 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l2_components/modal/components/ModalTitle.kt @@ -0,0 +1,47 @@ +package com.ivy.design.l2_components.modal.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.B1 +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.scope.ModalScope +import com.ivy.design.util.IvyPreview + +@Suppress("unused") +@Composable +fun ModalScope.Title( + text: String, + paddingStart: Dp = 32.dp, + color: Color = UI.colorsInverted.pure +) { + B1( + text = text, + modifier = Modifier + .padding(start = paddingStart) + .padding(top = 24.dp), + fontWeight = FontWeight.ExtraBold, + color = color + ) +} + +@Preview +@Composable +private fun Preview() { + val modal = IvyModal() + modal.show() + IvyPreview { + Modal(modal = modal, actions = {}) { + Title(text = "Title") + SpacerVer(height = 32.dp) + } + } +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l2_components/modal/scope/ModalActionsScope.kt b/design-system/src/main/java/com/ivy/design/l2_components/modal/scope/ModalActionsScope.kt new file mode 100644 index 0000000..d7a783a --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l2_components/modal/scope/ModalActionsScope.kt @@ -0,0 +1,36 @@ +package com.ivy.design.l2_components.modal.scope + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.runtime.Stable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.HorizontalAlignmentLine +import androidx.compose.ui.layout.Measured + +@Stable +class ModalActionsScopeImpl( + private val scope: RowScope +) : ModalActionsScope { + override fun Modifier.align(alignment: Alignment.Vertical): Modifier = with(scope) { + this@align.align(alignment) + } + + override fun Modifier.alignBy(alignmentLineBlock: (Measured) -> Int): Modifier = with(scope) { + this@alignBy.alignBy(alignmentLineBlock) + } + + override fun Modifier.alignBy(alignmentLine: HorizontalAlignmentLine): Modifier = with(scope) { + this@alignBy.alignBy(alignmentLine) + } + + override fun Modifier.alignByBaseline(): Modifier = with(scope) { + this@alignByBaseline.alignByBaseline() + } + + override fun Modifier.weight(weight: Float, fill: Boolean): Modifier = with(scope) { + this@weight.weight(weight, fill) + } +} + +@Stable +interface ModalActionsScope : RowScope \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l2_components/modal/scope/ModalScope.kt b/design-system/src/main/java/com/ivy/design/l2_components/modal/scope/ModalScope.kt new file mode 100644 index 0000000..0cd6e14 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l2_components/modal/scope/ModalScope.kt @@ -0,0 +1,36 @@ +package com.ivy.design.l2_components.modal.scope + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Stable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Measured +import androidx.compose.ui.layout.VerticalAlignmentLine + +@Stable +class ModalScopeImpl( + private val scope: ColumnScope +) : ModalScope { + @Stable + override fun Modifier.align(alignment: Alignment.Horizontal): Modifier = with(scope) { + this@align.align(alignment) + } + + @Stable + override fun Modifier.alignBy(alignmentLineBlock: (Measured) -> Int): Modifier = with(scope) { + this@alignBy.alignBy(alignmentLineBlock) + } + + @Stable + override fun Modifier.alignBy(alignmentLine: VerticalAlignmentLine): Modifier = with(scope) { + this@alignBy.alignBy(alignmentLine) + } + + @Stable + override fun Modifier.weight(weight: Float, fill: Boolean): Modifier = with(scope) { + this@weight.weight(weight, fill) + } +} + +@Stable +interface ModalScope : ColumnScope \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l3_ivyComponents/BackButton.kt b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/BackButton.kt new file mode 100644 index 0000000..7a1f644 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/BackButton.kt @@ -0,0 +1,36 @@ +package com.ivy.design.l3_ivyComponents + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.ivy.design.R +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview + +@Composable +fun BackButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + IvyButton( + modifier = modifier, + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = null, + icon = R.drawable.round_arrow_back_ios_24, + onClick = onClick, + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + ComponentPreview { + BackButton {} + } +} +// endregion \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l3_ivyComponents/Feeling.kt b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/Feeling.kt new file mode 100644 index 0000000..9ecafcb --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/Feeling.kt @@ -0,0 +1,22 @@ +package com.ivy.design.l3_ivyComponents + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +@Immutable +sealed interface Feeling { + @Immutable + object Positive : Feeling + + @Immutable + object Negative : Feeling + + @Immutable + object Neutral : Feeling + + @Immutable + object Disabled : Feeling + + @Immutable + data class Custom(val color: Color) : Feeling +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l3_ivyComponents/IvyDividerDot.kt b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/IvyDividerDot.kt new file mode 100644 index 0000000..73e9bd5 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/IvyDividerDot.kt @@ -0,0 +1,32 @@ +package com.ivy.design.l3_ivyComponents + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.util.ComponentPreview + +@Composable +fun IvyDividerDot( + modifier: Modifier = Modifier +) { + Spacer( + modifier = modifier + .size(4.dp) + .background(UI.colorsInverted.medium, CircleShape) + ) +} + + +@Preview +@Composable +private fun Preview() { + ComponentPreview { + IvyDividerDot() + } +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l3_ivyComponents/MoreInfoButton.kt b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/MoreInfoButton.kt new file mode 100644 index 0000000..4fdcbf2 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/MoreInfoButton.kt @@ -0,0 +1,36 @@ +package com.ivy.design.l3_ivyComponents + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.ivy.design.R +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview + +@Composable +fun MoreInfoButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + IvyButton( + modifier = modifier, + size = ButtonSize.Small, + visibility = Visibility.Low, + feeling = Feeling.Neutral, + text = null, + icon = R.drawable.outline_info_24, + onClick = onClick + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + ComponentPreview { + MoreInfoButton {} + } +} +// endregion \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l3_ivyComponents/ReorderButton.kt b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/ReorderButton.kt new file mode 100644 index 0000000..0cdbf24 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/ReorderButton.kt @@ -0,0 +1,36 @@ +package com.ivy.design.l3_ivyComponents + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.ivy.design.R +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview + +@Composable +fun ReorderButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + IvyButton( + modifier = modifier, + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = null, + icon = R.drawable.round_reorder_24, + onClick = onClick, + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + ComponentPreview { + ReorderButton {} + } +} +// endregion \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l3_ivyComponents/ReorderModal.kt b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/ReorderModal.kt new file mode 100644 index 0000000..1012db8 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/ReorderModal.kt @@ -0,0 +1,278 @@ +import android.annotation.SuppressLint +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.B1Second +import com.ivy.design.l1_buildingBlocks.IconRes +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Positive +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.previewModal +import com.ivy.design.util.IvyPreview +import com.ivy.resources.R + +// TODO: Not refactored legacy code. It can be improved! + +@Composable +fun BoxScope.ReorderModal( + modal: IvyModal, + level: Int = 1, + items: List, + onReorder: (reordered: List) -> Unit, + itemContent: @Composable RowScope.(Int, T) -> Unit, +) { + var reorderedList = remember(items) { listOf() } + + Modal( + modal = modal, + level = level, + contentModifier = Modifier, // fixes Compose compiler crash + actions = { + Positive(text = stringResource(R.string.reorder)) { + onReorder(reorderedList) + modal.hide() + } + } + ) { + Title(text = stringResource(R.string.reorder)) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + val mediumColor = UI.colors.medium + AndroidView( + modifier = Modifier + .fillMaxSize() + .background(UI.colors.pure) + .padding(vertical = 16.dp), + factory = { + RecyclerView(it).apply { + val itemTouchHelper = itemTouchHelper( + mediumColor = mediumColor + ) + adapter = ReorderAdapter( + itemTouchHelper = itemTouchHelper, + itemContent = itemContent, + onReorder = { reordered -> + reorderedList = reordered + } + ) + layoutManager = LinearLayoutManager(it) + itemTouchHelper.attachToRecyclerView(this) + + adapter().display(items) + } + }, + update = { + } + ) + } + } +} + + +@Suppress("UNCHECKED_CAST") +class ReorderAdapter( + private val itemTouchHelper: ItemTouchHelper, + private val itemContent: @Composable RowScope.(Int, T) -> Unit, + private val onReorder: (reordered: List) -> Unit +) : RecyclerView.Adapter.ItemViewHolder>() { + val data = mutableListOf() + + @SuppressLint("NotifyDataSetChanged") + fun display(items: List) { + data.clear() + data.addAll(items) + notifyDataSetChanged() + } + + fun moveItem(from: Int, to: Int) { + swap(from, to) + notifyItemMoved(from, to) + } + + private fun swap(from: Int, to: Int) { + val temp = data[from] + data[from] = data[to] + data[to] = temp + } + + fun onItemMoved(item: T, to: Int) { + data[to] = item + } + + fun onReorderInternalList() { + onReorder(data) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder { + return ItemViewHolder(ComposeView(parent.context)) + } + + override fun onBindViewHolder(holder: ItemViewHolder, position: Int) { + holder.display( + item = data[position], + ItemContent = itemContent, + position = position + ) + } + + override fun getItemCount() = data.size + + inner class ItemViewHolder( + itemView: View, + ) : RecyclerView.ViewHolder(itemView) { + + fun display( + item: T, + ItemContent: @Composable RowScope.(Int, T) -> Unit, + position: Int + ) { + (itemView as ComposeView).setContent { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + SpacerHor(width = 8.dp) + IconRes( + modifier = Modifier + .pointerInput(Unit) { + detectTapGestures( + onPress = { + itemTouchHelper.startDrag(this@ItemViewHolder) + } + ) + } + .testTag("reorder_drag_handle"), + icon = R.drawable.ic_drag_handle, + tint = UI.colors.neutral, + contentDescription = "reorder_${position}" + ) + ItemContent(absoluteAdapterPosition, item) + } + } + } + } +} + + +@Suppress("UNCHECKED_CAST") +fun itemTouchHelper( + mediumColor: Color, +): ItemTouchHelper { + // 1. Note that I am specifying all 4 directions. + // Specifying START and END also allows + // more organic dragging than just specifying UP and DOWN. + val simpleItemTouchCallback = + object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) { + var movedItem: T? = null + var finalTo: Int? = null + + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val adapter = recyclerView.adapter() + + val from = viewHolder.adapterPosition + val to = target.adapterPosition + + val targetItem = adapter.data[from] as? T ?: return false + + if (movedItem == null) { + movedItem = targetItem + } + finalTo = to + + adapter.moveItem(from, to) + + return true + } + + override fun onSwiped( + viewHolder: RecyclerView.ViewHolder, + direction: Int + ) { + // 4. Code block for horizontal swipe. + // ItemTouchHelper handles horizontal swipe as well, but + // it is not relevant with reordering. Ignoring here. + } + + // 1. This callback is called when a ViewHolder is selected. + // We highlight the ViewHolder here. + override fun onSelectedChanged( + viewHolder: RecyclerView.ViewHolder?, + actionState: Int + ) { + super.onSelectedChanged(viewHolder, actionState) + + if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) { + viewHolder?.itemView?.setBackgroundColor(mediumColor.toArgb()) + } + } + + override fun clearView( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ) { + super.clearView(recyclerView, viewHolder) + viewHolder.itemView.background = null + val adapter = recyclerView.adapter() + if (movedItem != null && finalTo != null) { + adapter.onItemMoved(movedItem!!, finalTo!!) + } + adapter.onReorderInternalList() + + movedItem = null + finalTo = null + } + } + return ItemTouchHelper(simpleItemTouchCallback) +} + +@Suppress("UNCHECKED_CAST") +private fun RecyclerView.adapter() = adapter as? ReorderAdapter + ?: error("Adapter not set or wrong adapter set to recyclerview.") + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = previewModal() + ReorderModal( + modal = modal, + items = (1..100).toList(), + onReorder = {}, + ) { _, item -> + SpacerHor(width = 8.dp) + B1Second(text = "Number: $item") + } + } +} +// endregion diff --git a/design-system/src/main/java/com/ivy/design/l3_ivyComponents/Visibility.kt b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/Visibility.kt new file mode 100644 index 0000000..bc48987 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/Visibility.kt @@ -0,0 +1,5 @@ +package com.ivy.design.l3_ivyComponents + +enum class Visibility { + Focused, High, Medium, Low +} diff --git a/design-system/src/main/java/com/ivy/design/l3_ivyComponents/WrapContentRow.kt b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/WrapContentRow.kt new file mode 100644 index 0000000..8984721 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/WrapContentRow.kt @@ -0,0 +1,75 @@ +package com.ivy.design.l3_ivyComponents + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + + +@Composable +fun WrapContentRow( + items: List, + itemKey: (T) -> String, + modifier: Modifier = Modifier, + horizontalMarginBetweenItems: Dp = 8.dp, + verticalMarginBetweenRows: Dp = 8.dp, + itemContent: @Composable (item: T) -> Unit +) { + if (items.isEmpty()) return + + Layout( + modifier = modifier, + content = { + for (item in items) { + key(itemKey(item)) { + itemContent(item) + } + } + } + ) { measurables, constraints -> + val childConstraints = constraints.copy( + minWidth = 0, minHeight = 0 + ) + + var x = 0 + + val placeables = measurables.map { + it.measure(childConstraints) + } + val itemHeight = placeables.maxOfOrNull { it.height } ?: 0 + + var height = 0 + + for (placeable in placeables) { + if (x + placeable.width > constraints.maxWidth) { + //item is overflowing -> move it to a new row + x = 0 + height += itemHeight + verticalMarginBetweenRows.roundToPx() + x += placeable.width + horizontalMarginBetweenItems.roundToPx() + continue + } + x += placeable.width + horizontalMarginBetweenItems.roundToPx() + } + + height += itemHeight + + layout(constraints.maxWidth, height) { + //Reset x + x = 0 + var y = 0 + + placeables.forEach { placeable -> + if (x + placeable.width > constraints.maxWidth) { + //item is overflowing -> move it to a new row + x = 0 + y += itemHeight + verticalMarginBetweenRows.roundToPx() + } + + placeable.place(x, y) + x += placeable.width + horizontalMarginBetweenItems.roundToPx() + } + } + } +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l3_ivyComponents/button/ArchiveButton.kt b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/button/ArchiveButton.kt new file mode 100644 index 0000000..bfef300 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/button/ArchiveButton.kt @@ -0,0 +1,47 @@ +package com.ivy.design.l3_ivyComponents.button + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import com.ivy.design.R +import com.ivy.design.l0_system.UI +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.util.ComponentPreview + +@Composable +fun ArchiveButton( + archived: Boolean, + color: Color = UI.colors.primary, + onArchive: () -> Unit, + onUnarchive: () -> Unit +) { + IvyButton( + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = Feeling.Custom(color), + text = null, + icon = if (archived) R.drawable.round_unarchive_24 else R.drawable.round_archive_24 + ) { + if (archived) onUnarchive() else onArchive() + } +} + + +// region Preview +@Preview +@Composable +private fun Preview_Archive() { + ComponentPreview { + ArchiveButton(archived = false, onArchive = {}, onUnarchive = {}) + } +} + +@Preview +@Composable +private fun Preview_Unarchive() { + ComponentPreview { + ArchiveButton(archived = true, onArchive = {}, onUnarchive = {}) + } +} +// endregion \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l3_ivyComponents/button/ButtonSize.kt b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/button/ButtonSize.kt new file mode 100644 index 0000000..e74bd9d --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/button/ButtonSize.kt @@ -0,0 +1,5 @@ +package com.ivy.design.l3_ivyComponents.button + +enum class ButtonSize { + Big, Small +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l3_ivyComponents/button/DeleteButton.kt b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/button/DeleteButton.kt new file mode 100644 index 0000000..7d112c1 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/button/DeleteButton.kt @@ -0,0 +1,34 @@ +package com.ivy.design.l3_ivyComponents.button + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.ivy.design.R +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.util.ComponentPreview + +@Composable +fun DeleteButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + IvyButton( + modifier = modifier, + size = ButtonSize.Small, + visibility = Visibility.High, + feeling = Feeling.Negative, + text = null, + icon = R.drawable.outline_delete_24, + onClick = onClick + ) +} + + +@Preview +@Composable +private fun Preview() { + ComponentPreview { + DeleteButton(onClick = {}) + } +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/l3_ivyComponents/button/IvyButton.kt b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/button/IvyButton.kt new file mode 100644 index 0000000..756871c --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/button/IvyButton.kt @@ -0,0 +1,272 @@ +package com.ivy.design.l3_ivyComponents.button + +import androidx.annotation.DrawableRes +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.R +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.rememberContrast +import com.ivy.design.l0_system.style +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l1_buildingBlocks.data.solid +import com.ivy.design.l1_buildingBlocks.data.solidWithBorder +import com.ivy.design.l1_buildingBlocks.glow +import com.ivy.design.l2_components.button.* +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.padding +import com.ivy.design.util.thenIf + +@Composable +fun IvyButton( + size: ButtonSize, + visibility: Visibility, + feeling: Feeling, + text: String?, + modifier: Modifier = Modifier, + @DrawableRes + icon: Int? = null, + shape: Shape = UI.shapes.fullyRounded, + typo: TextStyle = UI.typo.b2, + fontWeight: FontWeight = FontWeight.Bold, + hapticFeedback: Boolean = false, + onClick: () -> Unit, +) { + val feelingColor = feeling.toColor() + + val iconOnly = icon != null && text == null + val padding = when { + iconOnly -> padding(all = 12.dp) + icon != null -> padding( + start = 12.dp, + end = 20.dp, + top = 12.dp, + bottom = 12.dp, + ) + else -> padding(horizontal = 20.dp, vertical = 12.dp) + } + val overrideShape = if (iconOnly) UI.shapes.circle else shape + + val background = when (visibility) { + Visibility.Focused, Visibility.High -> solid( + shape = overrideShape, + color = feelingColor, + padding = padding, + ) + Visibility.Medium -> solidWithBorder( + shape = overrideShape, + solid = UI.colors.pure, + borderWidth = 2.dp, + borderColor = feelingColor, + padding = padding, + ) + Visibility.Low -> solid( + shape = overrideShape, + color = UI.colors.transparent, + padding = padding, + ) + } + + val textColor = when (visibility) { + Visibility.Focused, Visibility.High -> + rememberContrast(feelingColor) + Visibility.Medium -> UI.colorsInverted.pure + Visibility.Low -> feelingColor + } + val textStyle = typo.style( + color = textColor, + fontWeight = fontWeight, + textAlign = TextAlign.Center, + ) + + val sizeModifier = when (size) { + ButtonSize.Big -> modifier.fillMaxWidth() + ButtonSize.Small -> modifier + }.thenIf(visibility == Visibility.Focused) { + this.glow(feelingColor) + } + + when { + icon != null && text != null -> { + // Icon + Text + Btn.TextIcon( + modifier = sizeModifier, + mode = when (size) { + ButtonSize.Big -> Mode.FillMaxWidth + ButtonSize.Small -> Mode.WrapContent + }, + text = text, + iconLeft = icon, + iconPadding = 8.dp, + iconTint = textColor, + background = background, + textStyle = textStyle, + hapticFeedback = hapticFeedback, + onClick = onClick, + ) + } + icon != null && text == null -> { + // Icon only + Btn.Icon( + modifier = sizeModifier, + icon = icon, + iconTint = textColor, + background = background, + hapticFeedback = hapticFeedback, + onClick = onClick + ) + } + icon == null && text != null -> { + // Text only + Btn.Text( + modifier = sizeModifier, + text = text, + textStyle = textStyle, + background = background, + hapticFeedback = hapticFeedback, + onClick = onClick + ) + } + } +} + + +// region Previews +@Preview +@Composable +private fun PreviewCommon() { + ComponentPreview { + Column { + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Focused, + feeling = Feeling.Positive, + text = "Add", + icon = R.drawable.ic_vue_crypto_icon + ) {} + + SpacerVer(height = 12.dp) + + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Small, + visibility = Visibility.Focused, + feeling = Feeling.Positive, + text = "Add", + icon = R.drawable.ic_vue_crypto_icon + ) {} + + SpacerVer(height = 12.dp) + + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = Feeling.Negative, + text = "Error, okay?", + icon = null, + ) {} + + SpacerVer(height = 12.dp) + + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Small, + visibility = Visibility.Focused, + feeling = Feeling.Positive, + text = null, + icon = R.drawable.ic_round_add_24, + ) {} + + SpacerVer(height = 12.dp) + + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Small, + visibility = Visibility.Focused, + feeling = Feeling.Disabled, + text = "Disabled button", + icon = null, + ) {} + + SpacerVer(height = 12.dp) + + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Small, + visibility = Visibility.Low, + feeling = Feeling.Positive, + text = "Text-only", + icon = null + ) {} + + SpacerVer(height = 12.dp) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + SpacerHor(width = 16.dp) + + IvyButton( + modifier = Modifier.weight(1f), + size = ButtonSize.Big, + visibility = Visibility.High, + feeling = Feeling.Positive, + text = "Save", + icon = null, + ) {} + + SpacerHor(width = 12.dp) + + IvyButton( + modifier = Modifier.weight(1f), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Negative, + text = "Delete", + icon = null, + ) {} + + SpacerHor(width = 16.dp) + } + + SpacerVer(height = 16.dp) + + IvyButton( + modifier = Modifier.padding(start = 16.dp), + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = Feeling.Negative, + text = null, + icon = R.drawable.round_archive_24, + ) {} + } + } +} +// endregion + +// region Utility functions +@Composable +fun Feeling.toColor(): Color = when (this) { + Feeling.Positive -> UI.colors.primary + Feeling.Negative -> UI.colors.red + Feeling.Neutral -> UI.colors.neutral + Feeling.Disabled -> UI.colors.medium + is Feeling.Custom -> color +} +// endregion diff --git a/design-system/src/main/java/com/ivy/design/l3_ivyComponents/modal/DeleteConfirmationModal.kt b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/modal/DeleteConfirmationModal.kt new file mode 100644 index 0000000..3f1a9ea --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/l3_ivyComponents/modal/DeleteConfirmationModal.kt @@ -0,0 +1,60 @@ +package com.ivy.design.l3_ivyComponents.modal + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.R +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Body +import com.ivy.design.l2_components.modal.components.Negative +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.util.IvyPreview + +@Composable +fun BoxScope.DeleteConfirmationModal( + modal: IvyModal, + level: Int = 1, + message: String = "Are you sure that you want to delete it forever? " + + "Once deleted, it can NOT be undone.", + onDelete: () -> Unit, +) { + Modal( + modal = modal, + level = level, + actions = { + Negative( + text = "Delete", + icon = R.drawable.ic_round_delete_forever_24, + onClick = { + modal.hide() + onDelete() + } + ) + } + ) { + Title( + text = stringResource(R.string.confirm_deletion), + color = UI.colors.red + ) + SpacerVer(height = 24.dp) + Body(text = message) + SpacerVer(height = 48.dp) + } +} + +// region Previews +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = IvyModal() + modal.show() + DeleteConfirmationModal(modal = modal) {} + } +} +// endregion \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/util/Android.kt b/design-system/src/main/java/com/ivy/design/util/Android.kt new file mode 100644 index 0000000..a86e430 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/util/Android.kt @@ -0,0 +1,12 @@ +package com.ivy.design.util + +import android.os.Handler +import android.os.Looper + +fun postDelayed(delayMs: Long, run: () -> Unit) { + Handler(Looper.getMainLooper()).postDelayed({ run() }, delayMs) +} + +fun post(run: () -> Unit) { + Handler(Looper.getMainLooper()).post { run() } +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/util/Animation.kt b/design-system/src/main/java/com/ivy/design/util/Animation.kt new file mode 100644 index 0000000..a98dd0d --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/util/Animation.kt @@ -0,0 +1,30 @@ +package com.ivy.design.util + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring + +fun springBounce( + stiffness: Float = 500f, +) = spring( + dampingRatio = 0.75f, + stiffness = stiffness, +) + +fun springBounceFast() = springBounce( + stiffness = 2000f +) + +fun springBounceMedium() = spring( + dampingRatio = 0.75f, + stiffness = Spring.StiffnessLow, +) + +fun springBounceSlow() = spring( + dampingRatio = 0.75f, + stiffness = Spring.StiffnessVeryLow, +) + +fun springBounceVerySlow() = spring( + dampingRatio = 0.75f, + stiffness = 20f, +) \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/util/Compose.kt b/design-system/src/main/java/com/ivy/design/util/Compose.kt new file mode 100644 index 0000000..341117b --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/util/Compose.kt @@ -0,0 +1,81 @@ +package com.ivy.design.util + +import android.annotation.SuppressLint +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp + +@Composable +fun densityScope(densityScope: @Composable Density.() -> T): T { + return with(LocalDensity.current) { densityScope() } +} + +// TODO: Investigate if that's efficient +@SuppressLint("UnnecessaryComposedModifier") +fun Modifier.thenIf( + condition: Boolean, + otherModifier: @Composable Modifier.() -> Modifier +): Modifier = composed { + if (condition) { + this.then(otherModifier()) + } else this +} + + +// TODO: Investigate if that's efficient +@SuppressLint("UnnecessaryComposedModifier") +fun Modifier.thenWhen( + logic: @Composable Modifier.() -> Modifier? +): Modifier = composed { + this.logic() ?: this +} + +fun Modifier.consumeClicks() = clickableNoIndication { + //consume click +} + +fun Modifier.clickableNoIndication( + onClick: () -> Unit +): Modifier = composed { + this.clickable( + interactionSource = remember { MutableInteractionSource() }, + onClick = onClick, + role = null, + indication = null + ) +} + +@Deprecated("Just use DisposableEffect or SideEffect") +@SuppressLint("ComposableNaming") +@Composable +fun onEvent( + eventKey: Any = Unit, + cleanUp: () -> Unit = {}, + logic: () -> Unit +) { + DisposableEffect(eventKey) { + logic() + onDispose { cleanUp() } + } +} + +@Composable +fun Dp.toDensityPx() = densityScope { toPx() } + +@Composable +fun Int.toDensityDp() = densityScope { toDp() } + +@Composable +fun Float.toDensityDp() = densityScope { toDp() } + +fun openUrl(uriHandler: UriHandler, url: String) { + uriHandler.openUri(url) +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/util/GesturesExt.kt b/design-system/src/main/java/com/ivy/design/util/GesturesExt.kt new file mode 100644 index 0000000..e0c81fe --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/util/GesturesExt.kt @@ -0,0 +1,99 @@ +package com.ivy.wallet.utils + +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.input.pointer.pointerInput + +/** + * sensitivity - the lower the number, the higher the sensitivity + */ +fun Modifier.verticalSwipeListener( + sensitivity: Int, + onSwipeUp: () -> Unit = {}, + onSwipeDown: () -> Unit = {} +): Modifier = composed { + var swipeOffset by remember { mutableStateOf(0f) } + var gestureConsumed by remember { mutableStateOf(false) } + + this.pointerInput(Unit) { + detectVerticalDragGestures( + onDragEnd = { + swipeOffset = 0f + gestureConsumed = false + }, + onVerticalDrag = { _, dragAmount -> + //dragAmount: positive when scrolling down; negative when scrolling up + swipeOffset += dragAmount + + when { + swipeOffset > sensitivity -> { + //offset > 0 when swipe down + if (!gestureConsumed) { + onSwipeDown() + gestureConsumed = true + } + } + + swipeOffset < -sensitivity -> { + //offset < 0 when swipe up + if (!gestureConsumed) { + onSwipeUp() + gestureConsumed = true + } + } + } + + } + ) + } +} + +/** + * sensitivity - the lower the number, the higher the sensitivity + */ +fun Modifier.horizontalSwipeListener( + sensitivity: Int, + onSwipeLeft: () -> Unit = {}, + onSwipeRight: () -> Unit = {} +): Modifier = composed { + var swipeOffset by remember { mutableStateOf(0f) } + var gestureConsumed by remember { mutableStateOf(false) } + + this.pointerInput(Unit) { + detectHorizontalDragGestures( + onDragEnd = { + swipeOffset = 0f + gestureConsumed = false + }, + onHorizontalDrag = { _, dragAmount -> + //dragAmount: positive when scrolling down; negative when scrolling up + swipeOffset += dragAmount + + when { + swipeOffset > sensitivity -> { + //offset > 0 when swipe right + if (!gestureConsumed) { + onSwipeRight() + gestureConsumed = true + } + } + + swipeOffset < -sensitivity -> { + //offset < 0 when swipe left + if (!gestureConsumed) { + onSwipeLeft() + gestureConsumed = true + } + } + } + + } + ) + } +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/util/Insets.kt b/design-system/src/main/java/com/ivy/design/util/Insets.kt new file mode 100644 index 0000000..2090059 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/util/Insets.kt @@ -0,0 +1,88 @@ +package com.ivy.design.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Dp +import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat + +// region Insets Compose +/** + * @return system's bottom inset (nav buttons or bottom nav) + */ +@Composable +fun systemPaddingBottom(): Dp { + val rootView = LocalView.current + val densityScope = LocalDensity.current + return remember(rootView) { + val insetPx = + WindowInsetsCompat.toWindowInsetsCompat(rootView.rootWindowInsets, rootView) + .getInsets(WindowInsetsCompat.Type.navigationBars()) + .bottom + with(densityScope) { insetPx.toDp() } + } +} + +@Composable +fun keyboardPadding(): Dp { + val rootView = LocalView.current + val insetPx = + WindowInsetsCompat.toWindowInsetsCompat(rootView.rootWindowInsets, rootView) + .getInsets( + WindowInsetsCompat.Type.ime() or + WindowInsetsCompat.Type.navigationBars() + ) + .bottom + return insetPx.toDensityDp() +} +// endregion + + +@Composable +fun windowInsets(): WindowInsetsCompat { + val rootView = LocalView.current + return WindowInsetsCompat.toWindowInsetsCompat(rootView.rootWindowInsets, rootView) +} + +@Composable +fun systemWindowInsets(): Insets { + val windowInsets = windowInsets() + return windowInsets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.navigationBars()) +} + +@Composable +fun statusBarInset(): Int { + val windowInsets = windowInsets() + return windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()).top +} + +@Composable +fun navigationBarInset(): Int { + return navigationBarInsets().bottom +} + +@Composable +fun navigationBarInsets(): Insets { + val windowInsets = windowInsets() + return windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()) +} + + +@Composable +fun keyboardNavigationWindowInsets(): Insets { + val windowInsets = windowInsets() + return windowInsets.getInsets( + WindowInsetsCompat.Type.ime() + or WindowInsetsCompat.Type.systemBars() + ) +} + +@Composable +fun keyboardOnlyWindowInsets(): Insets { + val windowInsets = windowInsets() + return windowInsets.getInsets( + WindowInsetsCompat.Type.ime() + ) +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/util/Keyboard.kt b/design-system/src/main/java/com/ivy/design/util/Keyboard.kt new file mode 100644 index 0000000..92f7690 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/util/Keyboard.kt @@ -0,0 +1,99 @@ +package com.ivy.design.util + +import android.view.View +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.runtime.* +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Dp +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.doOnLayout + +@Composable +fun keyboardShiftAnimated(): State { + val systemBottomPadding = systemPaddingBottom() + val keyboardShown by keyboardShownState() + val keyboardShownInset = keyboardPadding() + return animateDpAsState( + targetValue = if (keyboardShown) + keyboardShownInset else systemBottomPadding, + ) +} + +@Composable +fun keyboardShownState(): State { + val keyboardOpen = remember { mutableStateOf(false) } + val rootView = LocalView.current + + DisposableEffect(Unit) { + val keyboardListener = { + // check keyboard state after this layout + val isOpenNew = isKeyboardOpen(rootView) + + // since the observer is hit quite often, only callback when there is a change. + if (isOpenNew != keyboardOpen.value) { + keyboardOpen.value = isOpenNew + } + } + + rootView.doOnLayout { + // get initial state of keyboard + keyboardOpen.value = isKeyboardOpen(rootView) + + // whenever the layout resizes/changes, callback with the state of the keyboard. + rootView.viewTreeObserver.addOnGlobalLayoutListener(keyboardListener) + } + + onDispose { + // stop keyboard updates + rootView.viewTreeObserver.removeOnGlobalLayoutListener(keyboardListener) + } + } + + return keyboardOpen +} + + +fun isKeyboardOpen(rootView: View): Boolean { + return try { + WindowInsetsCompat.toWindowInsetsCompat(rootView.rootWindowInsets, rootView) + .isVisible(WindowInsetsCompat.Type.ime()) + } catch (e: Exception) { + e.printStackTrace() + false + } +} + +class KeyboardController { + private val state = mutableStateOf(0) + + @OptIn(ExperimentalComposeUiApi::class) + @Composable + fun wire() { + val keyboardController = LocalSoftwareKeyboardController.current + LaunchedEffect(state.value) { + if (state.value > 0) { + keyboardController?.show() + } else { + keyboardController?.hide() + } + } + } + + fun show() { + if (state.value > 0) { + state.value = state.value + 1 + } else { + state.value = 1 + } + } + + fun hide() { + if (state.value < 0) { + state.value = state.value - 1 + } else { + state.value = -1 + } + } +} \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/util/Padding.kt b/design-system/src/main/java/com/ivy/design/util/Padding.kt new file mode 100644 index 0000000..f107e28 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/util/Padding.kt @@ -0,0 +1,54 @@ +package com.ivy.design.util + +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.ivy.design.l1_buildingBlocks.data.IvyPadding + +fun Modifier.paddingIvy(ivyPadding: IvyPadding?): Modifier = ivyPadding?.let { + this.padding( + top = it.top ?: 0.dp, + bottom = it.bottom ?: 0.dp, + start = it.start ?: 0.dp, + end = it.end ?: 0.dp + ) +} ?: this + +fun padding( + top: Dp? = null, + start: Dp? = null, + end: Dp? = null, + bottom: Dp? = null +): IvyPadding { + return IvyPadding( + top = top, + bottom = bottom, + start = start, + end = end + ) +} + +fun padding( + horizontal: Dp? = null, + vertical: Dp? = null +): IvyPadding { + return IvyPadding( + top = vertical, + bottom = vertical, + start = horizontal, + end = horizontal + ) +} + +fun padding( + all: Dp? = null +): IvyPadding { + return IvyPadding( + top = all, + bottom = all, + start = all, + end = all + ) +} + diff --git a/design-system/src/main/java/com/ivy/design/util/Preview.kt b/design-system/src/main/java/com/ivy/design/util/Preview.kt new file mode 100644 index 0000000..62ce8ba --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/util/Preview.kt @@ -0,0 +1,60 @@ +package com.ivy.design.util + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraintsScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import com.ivy.data.Theme +import com.ivy.design.api.IvyDesign +import com.ivy.design.api.IvyUI +import com.ivy.design.api.setAppDesign +import com.ivy.design.api.systems.ivyWalletDesign +import com.ivy.design.l0_system.UI + + +@Composable +fun ComponentPreview( + design: IvyDesign = ivyWalletDesign(theme = Theme.Auto, isSystemInDarkTheme = false), + content: @Composable BoxScope.() -> Unit +) { + IvyPreview(design = design) { + Box( + modifier = Modifier + .fillMaxSize() + .background(UI.colors.pure), + contentAlignment = Alignment.Center + ) { + content() + } + } +} + +@Composable +fun IvyPreview( + design: IvyDesign = ivyWalletDesign(theme = Theme.Auto, isSystemInDarkTheme = false), + Content: @Composable BoxWithConstraintsScope.() -> Unit +) { + SideEffect { + setAppDesign(design) + } + IvyUI( + Content = Content + ) +} + +@Composable +inline fun hiltViewModelPreviewSafe( + key: String? = null, +): VM? = + if (isInPreview()) null else hiltViewModel(key = key) + +@Composable +fun isInPreview(): Boolean = LocalInspectionMode.current \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/util/ScreenPlaceholder.kt b/design-system/src/main/java/com/ivy/design/util/ScreenPlaceholder.kt new file mode 100644 index 0000000..66ebe82 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/util/ScreenPlaceholder.kt @@ -0,0 +1,51 @@ +package com.ivy.design.util + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.B1 +import com.ivy.design.l1_buildingBlocks.H1 +import com.ivy.design.l1_buildingBlocks.SpacerVer + +@Composable +fun ScreenPlaceholder(text: String) { + Column( + modifier = Modifier + .fillMaxSize() + .background(UI.colors.pure), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + H1( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = text, + textAlign = TextAlign.Center + ) + SpacerVer(height = 4.dp) + B1( + text = "Work in progress...", + fontWeight = FontWeight.Bold, + color = UI.colors.orange, + ) + } +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + ScreenPlaceholder(text = "Preview") + } +} +// endregion \ No newline at end of file diff --git a/design-system/src/main/java/com/ivy/design/util/View.kt b/design-system/src/main/java/com/ivy/design/util/View.kt new file mode 100644 index 0000000..abe7f51 --- /dev/null +++ b/design-system/src/main/java/com/ivy/design/util/View.kt @@ -0,0 +1,14 @@ +package com.ivy.design.util + +import android.content.Context +import android.util.DisplayMetrics +import kotlin.math.roundToInt + +fun Float.dpToPx(context: Context): Float { + return this * + (context.resources.displayMetrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT) +} + +fun Int.dpToPx(context: Context): Int { + return this.toFloat().dpToPx(context).roundToInt() +} \ No newline at end of file diff --git a/docs/Developer-Guidelines.md b/docs/Developer-Guidelines.md new file mode 100644 index 0000000..9b99c53 --- /dev/null +++ b/docs/Developer-Guidelines.md @@ -0,0 +1,601 @@ +# Ivy Developer Guidelines + +## _:warning: WARNING: This guidelines are deprecated and obsoloete. Ignore this and go to **[docs/architecture](architecture/)** :warning:_ + +A short guide _(that'll evolve with time)_ with one and only goal - to **make us better developers.** + +[![PRs are welcome!](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/Ivy-Apps/ivy-wallet/blob/main/CONTRIBUTING.md) +[![Feedback is welcome!](https://img.shields.io/badge/feedback-welcome-brightgreen)](https://t.me/+ETavgioAvWg4NThk) +[![Proposals are highly appreciated!](https://img.shields.io/badge/proposals-highly%20appreciated-brightgreen)](https://t.me/+ETavgioAvWg4NThk) + +> PRs and proposals for typos, better wording, better examples or minor edits are very welcome! + +## Ivy Architecture (FRP) + +The Ivy Architecture follows the Functional Reactive Programming (FRP) principles. A good example for them is [The Elm Architecture.](https://guide.elm-lang.org/architecture/) + +### Motivation + +- Organize code _(Scalability)_ +- Reduce complexity _(Separation of responsibility)_ +- Reuse code (Composability) +- Limit side-effects _(Less bugs)_ +- Easier testing _(Pure, Controlled Effects, UI)_ +- Easier refactoring _(Strongly Typed)_ + +### Architecture graph + +```mermaid +graph TD; + +android(Android System) +user(User) +view(UI) +event(Event) +viewmodel(ViewModel) +action(Action) +pure(Pure) + +event -- Propagated --> viewmodel +viewmodel -- Triggers --> action +viewmodel -- "UI State (Flow)" --> view +action -- "Abstacts IO" --> pure +action -- "Composition" --> action +pure -- "Composition" --> pure +pure -- "Computes" --> action +action -- "Data" --> viewmodel + +user -- Interracts --> view +view -- Produces --> event +android -- Produces --> event +``` + +### Resources _(further learning)_ + +- [The Android Architecture](https://www.youtube.com/watch?v=TPWmfJq16rA&list=PLWz5rJ2EKKc8GZWCbUm3tBXKeqIi3rcVX) +- [Clean Code](https://www.oreilly.com/library/view/clean-code-a/9780136083238/) +- [Jetpack Compose Docs](https://developer.android.com/jetpack/compose/documentation) +- [Functional Programming](https://en.wikipedia.org/wiki/Functional_programming) +- [Category Theory for Programmers](https://github.com/hmemcpy/milewski-ctfp-pdf) +- [Functional Reactive Programming](https://www.youtube.com/watch?v=Agu6jipKfYw&t=1518s) +- [The Elm Architecture](https://guide.elm-lang.org/architecture/) +- [Maintainable Software Architecture with Haskell](https://www.youtube.com/watch?v=kIwd1D9m1gE) +- [The Dao of FP](https://github.com/BartoszMilewski/Publications/blob/master/TheDaoOfFP/DaoFP.pdf) +- [Don't walk away from complexity, run!](https://www.youtube.com/watch?v=4MEKu2TcEHM) +- [Lambda Calculus](https://www.youtube.com/watch?v=eis11j_iGMs) + +### 0. Data Model + +The Data Model in Ivy drives clear separation between `domain` pure data required for business logic w/o added complexity, `entity` database data, `dto` _(data transfer object)_ JSON representation for network requests and `ui` data which we'll displayed. + +Learn more at [Android Developers Architecture: Entity](https://www.youtube.com/watch?v=cfak1jDKM_4). + +#### Motivation + +- Reduce complexity _(JSON, DB specifics are isolated)_ +- Flexibility _(allows editing of Data object on different levels w/o breaking existing code)_ +- Easier domain logic _(unnecessary fields are removed)_ + +#### Data Model + +```mermaid +graph TD; + +data(Data) +entity(Entity) +dto(DTO) +ui_data(UI Data) + +ui(UI) +network(Network) +db(Database) +viewmodel(ViewModel) +domain("Domain (Action, Pure)") + +network -- Fetch --> dto -- Send --> network +dto --> data + +db -- Retrieve --> entity -- Persist --> db +entity --> data + +data --> entity +data --> dto + +data -- Computation input --> domain +domain -- Computation output --> viewmodel +viewmodel -- Transform --> ui_data +ui_data -- "UI State (Flow)" --> ui + +``` + +#### Example + +- `DisplayTransaction` + - UI specific fields +- `Transaction` + - domain data +- `TransactionEntity` + - has `isSynced`, `isDeletedFlags` db specific fields (Room DB annotations) +- `TransactionDTO` + - exactly what the API expects/returns (JSON) + +> Motivation: This separation **reduces complexity** and **provides flexibility** for changes. + +### 1. Event (UI interaction or system) + +The `Event` encapsulates outside world signals in an excepted format and abstracts user input and system events. + +An `Event` is generated from either user interaction with the UI or a system subscription _(e.g. Screen start, Time, Random, Battery level)_. + +#### Motivation + +- Simplifies domain logic. _(Abstracts Input)_ +- Makes ViewModel & Domain logic independent of Android & UI specifics. _(Dependency Inversion)_ + +#### Event Graph + +```mermaid +graph TD; + +user(User) +world(Outside World) +system(System Event) +ui(UI) +event(Event) + +user -- Interracts --> ui +world -- Triggers --> system + +ui -- Produces --> event +system -- Produces --> event +``` + +> Note: There are infinite user inputs and outside world signals. + +### 2. ViewModel (mediator) + +Triggers `Action` for incoming `Event`, transforms the result to `UI State` and propagates it to the UI via `Flow`. + +#### Motivation + +- Domain logic & UI independent of each other. _(Dependency Inversion)_ +- Defines the behavior for each UI and connects it with the corresponding domain logic. + +#### ViewModel + +```mermaid +graph TD; + +event(Event) +viewmodel(ViewModel) +action(Actions) +ui(UI) + +event -- Incoming --> viewmodel +viewmodel -- "Action Input" --> action +action -- "Action Output" --> viewmodel +viewmodel -- "UI State (Flow)" --> ui +``` + +### 3. Action (domain logic with side-effects) + +Actions accept `Action Input`, handles `threading`, abstract `side-effects` (IO) and executes specific domain logic by **composing** `pure` functions or other `actions`. + +#### Motivation + +- Encapsulates domain logic. +- Make business operations (actions) re-usable. _(Composability)_ +- Handles threading. _(Reduces Complexity)_ +- Simplifies the ViewModel. +- Independent of UI State. _(Dependency Inversion)_ +- Provide side-effects for the `pure` layer via Dependency Injection. _(DAOs, Retrofit, etc)_ + +#### Action Types + +- `FPAction()`: declarative FP style _(preferable)_ +- `Action()`: imperative OOP style + +#### Action Graph + +```mermaid +graph TD; + +input(Action Input) +output(Action Output) +pure(Pure Functions) +action(Action) + +io(IO) +dao(Database) +network(Network) +side-effect(Side-Effect) + +side-effect -- any --> io +dao -- DAOs --> io +network -- Retrofit --> io +io -- DI --> action + +action -- Composition --> action +action -- Threading --> action + +input --> action +action -- abstracted IO --> pure -- Result --> action +action -- Final Result --> output +``` + +#### Action Composition Examples + +##### Calculate Balance + +```Kotlin +//Example 1: Calculates Ivy's balance +class CalcWalletBalanceAct @Inject constructor( + private val accountsAct: AccountsAct, + private val calcAccBalanceAct: CalcAccBalanceAct, + private val exchangeAct: ExchangeAct, +) : FPAction() { + + override suspend fun Input.compose(): suspend () -> BigDecimal = recipe().fixUnit() + + private suspend fun Input.recipe(): suspend (Unit) -> BigDecimal = + accountsAct thenFilter { + withExcluded || it.includeInBalance + } thenMap { + calcAccBalanceAct( + CalcAccBalanceAct.Input( + account = it, + range = range + ) + ) + } thenMap { + exchangeAct( + ExchangeAct.Input( + data = ExchangeData( + baseCurrency = baseCurrency, + fromCurrency = (it.account.currency ?: baseCurrency).toOption(), + toCurrency = balanceCurrency + ), + amount = it.balance + ) + ) + } thenSum { + it.orNull() ?: BigDecimal.ZERO + } + + data class Input( + val baseCurrency: String, + val balanceCurrency: String = baseCurrency, + val range: ClosedTimeRange = ClosedTimeRange.allTimeIvy(), + val withExcluded: Boolean = false + ) +} +``` + +##### Overdue Transactions + +```Kotlin +//Example 2: Due transtions + due income/expense for a given filter +class DueTrnsInfoAct @Inject constructor( + private val dueTrnsAct: DueTrnsAct, + private val accountByIdAct: AccountByIdAct, + private val exchangeAct: ExchangeAct +) : FPAction() { + + override suspend fun Input.compose(): suspend () -> Output = + suspend { + range + } then dueTrnsAct then { trns -> + val dateNow = dateNowUTC() + trns.filter { + this.dueFilter(it, dateNow) + } + } then { dueTrns -> + //We have due transactions in different currencies + val exchangeArg = ExchangeTrnArgument( + baseCurrency = baseCurrency, + exchange = ::actInput then exchangeAct, + getAccount = accountByIdAct.lambda() + ) + + Output( + dueIncomeExpense = IncomeExpensePair( + income = sumTrns( + incomes(dueTrns), + ::exchangeInBaseCurrency, + exchangeArg + ), + expense = sumTrns( + expenses(dueTrns), + ::exchangeInBaseCurrency, + exchangeArg + ) + ), + dueTrns = dueTrns + ) + } + + data class Input( + val range: ClosedTimeRange, + val baseCurrency: String, + val dueFilter: (Transaction, LocalDate) -> Boolean + ) + + data class Output( + val dueIncomeExpense: IncomeExpensePair, + val dueTrns: List + ) +} + + +//Example 3: Overdue transactions + their income/expense +class OverdueAct @Inject constructor( + private val dueTrnsInfoAct: DueTrnsInfoAct +) : FPAction() { + + override suspend fun Input.compose(): suspend () -> Output = suspend { + DueTrnsInfoAct.Input( + range = ClosedTimeRange( + from = beginningOfIvyTime(), + to = toRange + ), + baseCurrency = baseCurrency, + dueFilter = ::isOverdue + ) + } then dueTrnsInfoAct then { + Output( + overdue = it.dueIncomeExpense, + overdueTrns = it.dueTrns + ) + } + + data class Input( + val toRange: LocalDateTime, + val baseCurrency: String + ) + + data class Output( + val overdue: IncomeExpensePair, + val overdueTrns: List + ) +} +``` + +> `Actions` are very similar to the "use-cases" from the standard "Clean Code" architecture. + +> Tip: You can compose actions and pure functions by using `then`, `thenMap`, `thenFilter`, `thenSum`. + +> Tip: When creating an `Action` make it as **atomic** as possible. The goal of each `Action` is to do one thing **efficiently** and to be **composable** with other actions like LEGO. + +### 4. Pure (domain logic, pure code) + +The `pure` layer must consist of only pure functions without side-effects. If the business logic requires, **side-effects must be abstracted**. + +#### Motivation + +- Avoid code duplication in `Action(s)`. _(Composability)_ +- Reduce complexity by abstracting domain logic from side-effects (DB, Network, etc) _(Effect-Based system)_ +- Easier Unit Testing for the core domain logic. +- Enables Property-based Testing. + +#### Function types + +- **Partial**: not defined for all input values + + ```Kotlin + @Partial(inCaseOf="b=0, produces ArithmeticException::class") + fun divide(a: Int, b: Int) = a / b + ``` + +- **Total**: defined for all input values but for the same input there is no guarantee to always return the same output (has side-effects) + + ```Kotlin + //It's defined in all cases but with each call returns a different output + + fun timeNowUTC(): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) + + //Produces logging side-effect which can be seen in Logcat + + fun logMessage( + msg: String + ) { + Log.d("DEBUG", msg) //SIDE-EFFECT! + } + ``` + +- **Pure**: defined for all input values and for the same input always returns the same result (has NO side-effects) + + ```Kotlin + @Pure + fun sum(a: Int, b: Int) = a + b + + @Pure + fun logMessage( + msg: String, + + @SideEffect + log: (String) -> Unit + ) { + log("DEBUG: $msg") + } + ``` + +Each `@Pure` function must be **total** and its `@SideEffect`(s) if any abstracted. + +> Rule: If a pure function is called with the **same input** and mocked side-effects it must always produce the **same output**. + +#### Pure Graph + +```mermaid +graph TD; + +input(Input) +pure(Pure) +side-effect(IO / Side-Effect) +lambda("@SideEffect Lambda") +output(Output) + +side-effect -- Implements --> lambda + +input -- Data --> pure +lambda -- Abstracted Effects --> pure + +pure -- Calculates --> output +``` + +#### Code Example + +```Kotlin +//domain.action (NOT PURE) +class ExchangeAct @Inject constructor( + private val exchangeRateDao: ExchangeRateDao, +) : FPAction>() { + override suspend fun Input.compose(): suspend () -> Option = suspend { + exchange( + data = data, + amount = amount, + getExchangeRate = exchangeRateDao::findByBaseCurrencyAndCurrency then { + it?.toDomain() + } + ) + } + + data class Input( + val data: ExchangeData, + val amount: BigDecimal + ) +} + + +//domain.pure (PURE) +@Pure +suspend fun exchange( + data: ExchangeData, + amount: BigDecimal, + + @SideEffect + getExchangeRate: suspend (baseCurrency: String, toCurrency: String) -> ExchangeRate?, +): Option { + //PURE IMPLEMENTATION + //.... +} +``` + +> Tip: Make `pure` functions small, atomic and composable. + +### 5. UI (@Composable) + +Renders the `UI State` that the user sees, handles `user input` and transforms it to `events` which are propagated to the `ViewModel`. **Do NOT perform any business logic or computations.** + +#### Motivation + +- UI independent of logic. +- Transform UI State into UI on the screen. +- Abstracts the ViewModel from UI specifics. + +```mermaid +graph TD; + +user(User) +uiState("UI State (Flow)") +ui("UI (@Composable)") +event(Event) +viewmodel(ViewModel) + +user -- Interracts --> ui +ui -- Produces --> event +event -- "onEvent()" --> viewmodel +viewmodel -- "Action(s)" --> uiState +uiState -- "Flow#collectAsState()" --> ui +``` + +> Exception: The UI layer may perform in-app `navigation().navigate(..)` to reduce boiler-plate and complexity. + +### 6. IO (side-effects) + +Responsible for the implementation of IO operations like persistence, network requests, randomness, date & time, etc. + +#### Motivation + +- Encapsulate IO effects. _(Reduce Complexity)_ +- Abstracts `Action(s)` from IO implementation. +- Re-usable IO. _(Composability)_ + +#### Side-Effects + +- **Room DB**, local persistence +- **Shared Preferences**, local persistence + - key-value pairs persistence + - _migrated to DataStore_ +- **Retrofit**, Network Requests (REST) + - send requests + - parse response JSON with GSON + - transform network errors to `NetworkException` +- **Randomness** + - `UUID` generation +- **Date & Time** + - current Date & Time (`timeNowUtc`, `dateNowUtc`) + - Date & Time formatting using user's `Locale` + +### 7. Android System (side-effects) + +Responsible for the interaction with the Android System like launching `Intent`, sending `notification`, receiving `push messages`, `biometrics`, etc. + +#### Motivation + +- Abstracts `Action(s)` and `UI` from the Android System and its specifics. _(Reduce Complexity)_ +- Re-usable Android System effects. _(Composability)_ + +--- + +## Testing + +One of the reasons for the Ivy Architecture is to support painless, effective and efficient testing of the code base. + +### Motivation + +- Verifies whether the code works as expected before reaching manual QA. _(Stability)_ +- Easier refactoring. _(Tests will protect us)_ +- Faster and more reliable QA. _(Tests will ensure that the core functionality is working)_ +- Instant feedback for Pull Requests. _(CI/CD)_ + +### Unit Testing + +Tests whether the code is working correctly in the expected cases. _(hard-coded cases)_ + +#### Layers + +- `Data Model` +- `Pure` + +### Property-Based Testing + +Tests correctness in unexpected cases by randomly generated test cases in a given range of possible input. _(auto-generated tests cases)_ + +#### Layers + +- `Data Model` +- `Pure` + +### End-to-end Android Tests + +Tests the integration and correctness with the Android SDK & System on specific Android API version. _(end-to-end for logic)_ + +#### Layers + +- `Action` +- `IO` +- `Android System` + +### UI Android Tests + +Tests everything from the perspective of a user using the UI. Imagine it like an automated QA going through pre-defined scenarios. _(end-to-end for everything)_ + +#### Layers + +- `UI` (Compose) + +--- + +_Version 1.3.2_ + +_Feedback and proposals are highly appreciated! Let's spark technical discussion and make Ivy and the Android World better! :rocket:_ diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..0aac7fe --- /dev/null +++ b/docs/README.md @@ -0,0 +1,7 @@ +# Docs: Ivy Wallet's library + +A folder where you can find the most tech information about the Ivy Wallet's repo. + +- `docs/algorithms`: Important algorithms behind Ivy Wallet's business logic. +- `docs/architecture`: Architecture Decision Records (ADRs) for the big architecture challenges in Ivy wallet. +- `docs/resources`: helpful learning materials like books, YouTube videos and articles. \ No newline at end of file diff --git a/docs/algorithms/Account-Cache Algo.md b/docs/algorithms/Account-Cache Algo.md new file mode 100644 index 0000000..2b329e3 --- /dev/null +++ b/docs/algorithms/Account-Cache Algo.md @@ -0,0 +1,122 @@ +# "Account-Cache" Algo + +An algorithm for efficiently calculating account's `RawStats`: `Income` and `Expense` using caching. + +## Algorithm + +![Account-Cache-Diagram](../../assets/account_cache_algo.svg) +**[--> View the diagram full-screen <--](https://raw.githubusercontent.com/Ivy-Apps/ivy-wallet/develop/assets/account_cache_algo.svg)** + +The purpose of the "Account-Cache" algorithm is to optimze the way the account's balance (and overall) in Ivy Wallet is calculated. + +To calculate the balance of an account Ivy Wallet goes through all transactions of that account and executes the [Calc Algo](./Calc%20Algo.md). + +> Account's Balance = $\Sigma$(of all incomes _in account's currency_) - $\Sigma$(of all expenses _in account's currency_) + +_This algorithm must iterate through all transactions to calculate the balance and that becomes inefficient when you use the app for a few years and have 3k+ transactions. But we can do better than that!_ + + +## Invalidate cache algorithm + +![Invalidate-Account-Cache-Diagram](../../assets/account_cache_invalidate_algo.svg) +**[--> View the diagram full-screen <--](https://raw.githubusercontent.com/Ivy-Apps/ivy-wallet/develop/assets/account_cache_invalidate_algo.svg)** + +### 0. Introducing the cache + +We must persist the last calculated `RawStats` for that account because it's independent of the exchange rates. + +_For example, if my account has $10 and €10, we can't simply store $20.82 because when any of the USD or EUR exchange rate changes it'll because invalid._ + +**"accounts_cache" DB table** +- account_id: `String` (PK) +- incomes_json: `String`, JSON {"EUR":12;0, USD":13.0} +- expenses_json: `String`, JSON {"EUR":12;0, USD":13.0} +- incomes_count: Int +- expenses_count: Int + +JSON string for the incomes and expenses is preffered because having another table will just create overhead and complexity. + +> Room DB is preferred over the DatStore (key-value pairs) because some users might have many accounts and SQLite is more optimized for that purpose. + +### 1. Lookup for a cache entry for the account? `O(log(# of accounts)) time | O(1) space` + +> _Note: All Kotlin code in pseudo-code._ + +```kotlin +// SELECT * from accounts_cache WHERE accountId = ? LIMIT 1; +val cacheEntry = accountCacheDao.findByAccountId(accountId) +``` + +_Complexity: In practice `# of account cache entries` = `# of accounts`. The `account_id` column in the SQLite table is indexed and [SQLite uses a B-Tree for the indexes](https://www.sqlitetutorial.net/sqlite-index) => it takes O(logn) time to find cache entry for the account._ + + +### [CACHE NOT FOUND] 1. Fetch all transactions for the account `O(log(# of all trns) + # of trns for the account) time | O(# of trns for the account) space` + +```kotlin +// SELECT ... FROM transactions WHERE accountId = ? +calcTrnDao.findAllByAccountId(accountId) +``` + +_Complexity: The `accountId` column is indexed and it takes SQLite `O(log(# of all trns)) time` to find the row ids for the transactions with the target accountId - search in a B-Tree. Then it takes `O(# of trns for the account) time` to iterate them. This operation only allocates new memory for the account's transactions - `O(# of trns for the account) space`._ + +### [CACHE NOT FOUND] 2. Execute [`RawStats` from Calc Algo](Calc%20Algo.md) `O(# of all account's trns) time | O(# of unique currencies in the account) space` + +Iterates through all transactions and sums their incomes and expenses by currency. + +### [CACHE NOT FOUND] 3. Update the account's cache `O(# account cache entries) time | O(1) space` + +```kotlin +// UPSERT +accountCacheDao.save() +``` + +> It might take `O(# account cache entries) time` if the B-Tree indexes needs to be re-balanced. + +--- + +### [CACHED] 1. Fetch account's transactions only after the cache time `O(log(?) time | O(?) space` + +> Transaction's `trnTime` column is indexed. + +```kotlin +// SELECT ... FROM transactions WHERE accountId = ? AND time = ? +caclTrnsDao.findAllByAccountAndAfter(accountId, cacheTime) +``` + +_Complexity: Depends on the SQLite engine strategy, cannot be estimated._ + + +### [CACHED] 2. Execute [`RawStats` from Calc Algo](Calc%20Algo.md) `O(# of account trns only after cache's time) time | O(# of unique currencies in the account) space` + +### [CACHED] 3. Sum the cached values by the newly fetched ones `O(# of unique currencies) space-time` + +[CACHED] 4. Update the account's cache `O(# account cache entries) time | O(1) space` + +## Complexity + +The overall complexity of the algorithm is + +**Best-case (cache exists)** +- Lookup cache: `O(log(# of accounts)) time | O(1) space` +- Trns for account only after cache time: `O(?) space-time` _(depends on SQLite optimizer)_ +- Raw Stats: `O(# of account trns only after cache's time) time | O(# of unique currencies in the account) space` +- Sum the cached values + the newly fetched ones: `O(# of unique currencies) space-time` +- Update the cache: `O(# account cache entries) time | O(1) space` +- **Overall time: `O(log(# of all accounts) + # of account's trns after cache time + # of unique currencies in acc + # of account cache entries)`** +- **Overall space: `O(? but no more than worst-case)`** + +> **Practical time: `O(# of account's trns after cache time)`** + +> **Practical space: `O(# of account's trns after cache time)`** + +**Worst-case (no cache)** +- Lookup cache: `O(log(# of accounts)) time | O(1) space` +- All trns for account: `O(log(# of all trns) + # of trns for the account) time | O(# of trns for the account) space` +- Raw Stats: `O(# of all account's trns) time | O(# of unique currencies in the account) space` +- Update the cache: `O(# of account cache entries) time | O(1) space` +- **Overall time: `O(log(# of accounts) + log(# of all trns) + # of trns for the account + # of account cache entries)`** +- **Overall space: `O(# of trns by "account_id" + # of unique currencies in the account)`** + +> **Practical time: `O(# of trns by "account_id" + log(# of all trns))`** + +> **Practical space: `O(# of trns by "account_id")`** \ No newline at end of file diff --git a/docs/algorithms/Calc Algo.md b/docs/algorithms/Calc Algo.md new file mode 100644 index 0000000..d65067e --- /dev/null +++ b/docs/algorithms/Calc Algo.md @@ -0,0 +1,143 @@ +# Calc Algo + +Calculates Income and Expense values in a given currency provided a list of transactions. + +This algorithm is behind almost all numbers that you see in Ivy Wallet. + +![calc_algo.svg](../../assets/calc_algo.svg) +**[--> View the diagram full-screen <--](https://raw.githubusercontent.com/Ivy-Apps/ivy-wallet/develop/assets/calc_algo.svg)** + +## Algorithm + +Aggregates incomes and expenses in a given currency. +- Input: `[CalcTrn]` and an output currency +```kotlin +data class Input( + val trns: List, + val outputCurrency: CurrencyCode +) + +data class CalcTrn( + val amount: Double, + val currency: Currency, + val type: TransactionType, +) +``` +- Output: `RawStats` +```kotlin +data class RawStats( + val incomes: Map, + val expenses: Map, + val incomesCount: Int, + val expensesCount: Int, +) +``` + +### Steps + +_(pseudo-code)_ + +**A) Raw Stats:** `O(# of trns) time | O(# of unique currencies) space` `pure` + +Aggregates transactions' income and expense by currency. The purpose is the result to be independant of the exchange rates and the base currency. + +1. Initialization: `O(1) space-time` +```kotlin +val incomes = mutableMapOf() +val expenses = mutableMapOf() +var incomesCount = 0 +var expensesCount = 0 +``` + +2. Loop through transactions and aggregate: `O(# of trns) time | O(# of unique currencies) space` +```kotlin +trns.forEach { + when(type) { + Income -> { + incomesCount++ + incomesMap[it.currency] += it.amount + } + Expense -> { + expensesCount++ + expenses[it.currency] += it.amount + } + } +} +``` + +_Complexity: it iterates through each transaction (time) and creates a map key for each unique currency (space)._ + +**B) Get the exchange rates** `O(# of rates + # of overriden rates) space-time` `✨base-currency` `✨rates` `✨overriden-rates` + +Retrieve from the local DB the latest exchange rates stored considering the ones that are manually overriden. + +> RX: `✨X` means reacts to X + +1. Retrieve the base currency from the DataStore: `O(1) space-time` +```kotlin +DataStore.preferrences.map { it[key] } +``` + +2. Retrieve all exchange rates from Room DB `O(# of rates) time | O(# of rates for the base currency) space` `✨base-currency` +```kotlin +@Query("SELECT currency, amount FROM exchange_rates WHERE baseCurrency = :baseCurrency") + fun findAllByBaseCurrency(baseCurrency: String): Flow> +``` + +3. Retrieve all manually overriden exchange rates from Room DB `O(# of overidden rates) space-time` `✨base-currnecy` + +4. Replace automatic rates with the overriden ones `O(# of rates + # of overriden rates) time | O(# of rates + # of overriden rates) space` `✨rates` `✨overriden-rates` +```kotlin +combine(rates, overridenRates) { + val res = mutableMapOf() + rates.forEach { + res[it.key] = it.value + } + overridenRates.forEach { + res[it.key] = it.value + } + res +} +``` + + +**C) Exchange RawStats** `O(# of unique currencies) time | O(1) space` `✨rates` + +Exchange the aggregated incomes and expenses in different currencies from the `RawStats` into a single income and expense in the `outputCurrnecy` selected. + +1. Initialization `O(1) space-time` +```kotlin +var incomeOutCurr = 0.0 +var expenseOutCurr = 0.0 +``` + +2. Iterate through `incomes: Map`, exchange in output curr and sum them `O(# of unique income currencies) time | O(1) space` `✨rates` +```kotlin +incomes.forEach { (curr, amount) -> + incomOutCurr += exchange(rates, curr, amount, outputCurr) +} +``` + +> `exchange()` takes `O(1) space-time + +3. Repeat Step 2. for `expesnes` `O(# of unique expense currencies) time | O(1) space` `✨rates` + + +## Complexity + +The overall complexity of the "Calc" algorithm is the complexity of the steps performed. + +**Steps:** +- **RawStats:** `O(# of trns) time | O(# of unique currencies)` +- **Exchange rates:** `O(# of rates + # of overriden rates) space-time` +- **Exchange in output currency**: `O(# of unique currencies) time | O(1) space` + + +### Conclusion +> **O(# of trns + # of rates + # of overriden rates) time** + +> **O(# of rates + # of overriden rates) space** + +> Reacts to: `✨base-currency`, `✨rates`, `✨overriden-rates` + +The practical cost of this algorithm is **`O(# of input trns + # of rates) time | O(# of rates) space`** because # of overriden rates << # of rates. \ No newline at end of file diff --git a/docs/algorithms/README.md b/docs/algorithms/README.md new file mode 100644 index 0000000..28ffecb --- /dev/null +++ b/docs/algorithms/README.md @@ -0,0 +1,3 @@ +# Algorithms + +Ivy Wallet's domain logic algorithms visualized with **diagrams**, explained **step by step** with included detailed **space-time complexity** analysis. \ No newline at end of file diff --git a/docs/algorithms/Sync Algo.md b/docs/algorithms/Sync Algo.md new file mode 100644 index 0000000..6f33e50 --- /dev/null +++ b/docs/algorithms/Sync Algo.md @@ -0,0 +1,18 @@ +# Sync Algo + +# _🚧 WIP... 🚧_ + +An algorithm for syncing Ivy Wallet's data between multiple devices without central authority. + +- Fetch the entire JSON zip from Google Drive. +- Fetch all local ids + last_updated + +## Things to write locally + +- id in Drive && id !in Local +- id in Drive && id in local && drive.last_updated > local.last_updated + +## Things to write to Drive + +- id !in Drive && id in Local +- in \ No newline at end of file diff --git a/docs/architecture/ADR#1 Modularization (Done).md b/docs/architecture/ADR#1 Modularization (Done).md new file mode 100644 index 0000000..90a669b --- /dev/null +++ b/docs/architecture/ADR#1 Modularization (Done).md @@ -0,0 +1,100 @@ +# Architecture Decision Record (ADR) #1: Modularization ✅ + +Split the `:app` monolith into many small modules by feature or responsibility. + +**Goal:** divide, refactor and conquer. + +## Problem + +Ivy Wallet is progessively becoming bigger and bigger by introducing new features and user requests. +With each new thing added the code becomes more and more coupled leading to higher complexity, harder refactoring, +and more bugs & glitches. + +## Solution + +Split the `:app` logically by: +- **feature modules:** e.g. `:home`, `:loans`, `:accounts` ... +- **responsibility (shared) modules:** e.g. `:exchange`, `:ui-components`, `:persitence`, `:network`, ... + +### Benefits +- **No coupling:** you can't import functions or classes from `:app` or other feature modules. +- **Less complexity:** to re-use code it have to defined-well in a shared module. +- **Faster build time:** if done well only the affected modules will be recompiled and NOT the entire monolith. +- **Stable way to scale and add new feautres:** when developing new features (e.g. Goals) contributors can add +a new module `:goals` and develop there w/o risk of breaking `:categories`, `:accounts` or core functionality. +- **Easier maintenance:** problematic modules can be excluded and they won't break other features. +- **Faster unit & UI tests:** we can optimize the CI to run only the tests for the changed modules. + +### Drawbacks +- **A lot of work required:** modularization is big iniciative and requires a lof ot work +because of our coupled code in `:app`. +- **Will break R8:** we have to redesign how are `minify` work. +- **Will break R (resources):** as our resources are monolithic it'll take some time to de-couple and re-organize them. +- **Complex Gradle KTS setup:** compared to a monolith is way harder to configure all gradle modules +and their dependencies efficiently. +- **Learning curve for contributors:** contributors will have to understand our modules structure, +how to create modules and how to navigate between them. +- **Until fully completed it's worse than a monolith:** a lot of `temp` modules or junkyards like `app-base`. + +### Alternatives + +- Stay with `:app` monolith. +- Modularize only by domain - `:ui`, `:actions`, `:persistence`, `:network`, ... + +## Implementation Strategy + +### When to create a new module? +- When a group of code or resources can be logically organized and isolated +from other dependencies. Good examples: + - `:exchange`: holds all exchange logic (created) + - `:date-time`: holds all datetime conversion and formatting code (not created) + - `:backups`: all logic for JSON zip backs - import & export. +(not created- @Vishwa) + +- When code or components from a monolithic "trashy" `:temp-` modules can +be logically extracted. Example `:ui-components-old` -> `:modals` +-> `:account-modals`, `:category-modals`, `:loan-modals`, .. + +- When large feature modules can logically split futher. Example `:main` -> +`:more-menu`, `:customer-journey`, `:home`, `:accounts`. + +> ❗To create a new module run `runhaskel scripts/create_module.hs`. + +### When to move "stuff" to another module? + +- When a code in a shared module is only used in one feature module. +Example: `CategoryAmount` data class in `:app-base` -> `:pie-charts`. + +- When a code in a shared module can be used only in a few logically connected feature modulues. + +- When a code in a monolithic shared module can be isolated further. +Example: The modals UI from `:ui-components-old`to `:modals` or even further to many smaller modals +modules like `:amount-input-modals`, ... + +### Common module dependencies +- `:common`: contains all common Kotlin deps +- `:ui-common`: `:common` + Jetpack Compose + other UI related stuff +- `:data-model`: The data model behind Ivy Wallet. ⚠️ Add only data classes w/o methods in it -> +module should NOT contain behavior. (we'll cover it another ADR) +- `:temp-persistence`: everything persistence related => will be split further +- `:temp-domain`: all Actions, pure functions and "Logic" => will be split further +- `:app-base`: all resources + random stuff - must be reworked +- `:ui-components-old`: all legacy Ivy Wallet components => will be split and deprecated. +- `:screens`: data classes for all screens in the app, used for navigation + +### Temporary (trashy) modules + +This modules are just bad but we temporarily need them to have the project compiling. +If you want to help consider splitting them logically further. + +- `:app-base` +- `:temp-domain` +- `:ui-components-old` +- `:temp-network` +- `:temp-persistence` + +## Further Reading +(TODO: Add resources) +- [Resource 1]() +- [Resource 2]() +- ... diff --git a/docs/architecture/ADR#2 Going Reactive (Done).md b/docs/architecture/ADR#2 Going Reactive (Done).md new file mode 100644 index 0000000..9e60f5a --- /dev/null +++ b/docs/architecture/ADR#2 Going Reactive (Done).md @@ -0,0 +1,23 @@ +# Architecture Decision Record (ADR) #2: Going Reactive ✅ + +1) Replace all operations in the domain's logic with Kotlin's `Flow` asynchronous data-stream API so they'll automatically react to data changes by emitting new values. + +2) Leverage reactive data sources: +- Room DB Flow DAOs +- DataStore Flow + +3) Encapsulate use-cases in [FlowAction]([core/actions/](https://github.com/Ivy-Apps/ivy-wallet/blob/develop/core/actions/src/main/java/com/ivy/core/action/FlowAction.kt)). + +## Problem + +Ivy Wallet UI should update every time [transaction, account, category, exchange rate, base currency, ...] change. Doing this imperatively inceases complexity and make ViewModels gigantous! + +## Solution + +Go reactive by migrating to [Kotlin Flows](https://kotlinlang.org/docs/flow.html). See [Flow Actions](https://github.com/Ivy-Apps/ivy-wallet/blob/develop/core/actions/src/main/java/com/ivy/core/action). + +## Benefits + +- The app will react automatically to data changes. +- Flows can introduce out-of-the-box caching and efficient async processing. +- Reducing complexity by not thinking which states we need to update imperatively (manually). \ No newline at end of file diff --git a/docs/architecture/ADR#3 Jetpack Navigation (Done).md b/docs/architecture/ADR#3 Jetpack Navigation (Done).md new file mode 100644 index 0000000..39b5672 --- /dev/null +++ b/docs/architecture/ADR#3 Jetpack Navigation (Done).md @@ -0,0 +1,19 @@ +# Architecture Decision Record (ADR) #3: Jetpack Navigation ✅ + +Currently, the Ivy Wallet app uses custom Navigation implementation from our [Ivy FRP](https://github.com/Ivy-Apps/ivy-frp). + +Migrating to [Jetpack Navigation](https://developer.android.com/guide/navigation) will provide us many features that our navigation lack like deep links support, nav graph. + +## Problem + +[Ivy Navigation](https://github.com/Ivy-Apps/ivy-frp/blob/main/frp/src/main/java/com/ivy/frp/view/navigation/Navigation.kt) is inefficient, lack support and developers aren't familiar with it. On the other hand using Google's Jetpack Compose Navigation will solve that and give us access to the features yet to come. + +- we need to handle back navigation ourselves which increases complexity. + +## Benefits +- Out of the box, back navigation. +- Deep links support. +- Follow [Jetpack Compose Navigation](https://developer.android.com/jetpack/compose/navigation) best practices. +- Happier developers. +- Actively-developed navigation component. +- Enjoy future optimizations and integrations with Jetpack libraries. \ No newline at end of file diff --git a/docs/architecture/ADR#4 Core Module (Done).md b/docs/architecture/ADR#4 Core Module (Done).md new file mode 100644 index 0000000..52aa3e3 --- /dev/null +++ b/docs/architecture/ADR#4 Core Module (Done).md @@ -0,0 +1,71 @@ +# Architecture Decision Record (ADR) #4: `:core` module and modules re-structure ✅ + +## _This ADR is WIP and will likely change..._ + +After executing [ADR#1: Modularization](ADR#1%20Modularization%20(Done).md) we ended up with confusing modules structure and a lot of `:temp`, `:app-base` and other "workaround" modules required to build the app successfully. + +We plan to address that by: +1) Organize our modules better. +2) Leverage submodules (e.g. `:core:actions`). + +## Problem + +After the migration to modularized migration we ended up with a [big ball of mud](https://en.wikipedia.org/wiki/Big_ball_of_mud) scattered around multiple modules w/o logical structure. +- complexity++ +- coupling. +- hard for developers to add the right dependencies to new modules. +- a lot of unnecessary dependencies. +- hard to navigate the project. +- slower build times. +- it's bad! + +## Solution + +Create a logical structure of modules and submodules having in mind that one-day we may want to migrate this project to [KMP (Kotlin Multiplatform)](https://kotlinlang.org/docs/multiplatform.html). + +**Goal:** +- reduce the number of root modules. +- make navigation easier. +- remove coupling and unncessary dependencies. +- reduce complexity. + +### 1) Domain module `:core`. +- `:core:data-domain`: Ivy's data model [`Transaction`, `Account`, `Category`, ...] +- `:core:data-ui`: transformation of `data-domain` so it's optmized for UI and Jetpack Compose +- `:core:functions`: pure functions +- `:core:actions`: re-usable use-cases for domain logic +- `:core:ui`: re-usable UI components providing extension functions for `data-ui` +- `:core:persistence`: handling local persistence RoomDB + +### 2) Move all resources [strings, drawables, ...] to `:resources`. +No code module, containing only resources. Why? +- It'll make it easy to access all Ivy icons and strings when you develop. +- It'll be easy for contributors to manage `strings.xml` and translations. +- R8 will optimize so there's no big cost. + +### 3) Remove `:screens` and migrate to `:navigation` +See [ADR#3: Jetpack Navigation](ADR#3%20Jetpack%20Navigation%20(WIP).md). TL;DR; +- define all possible nav destinations in `:navigation`. +- wire nav destionations with @Composable UI in `:app` + +### 4) Other key modules +- `:common`: light weight module used to provide common deps for any module. +- `:design-system`: Ivy Design system +- `:sync`: logic for syncing your data to Ivy Cloud (deprecated) and soon Google Drive + +### 4) Continue grouping scattered modules in bigger modules composed of submodules. + +### 5) Modules to remove +- `:state` because `Flow` will handle it under the hood. +- all `temp-*` modules. +- `:ui-common` -> `:design-system`. +- `:ui-components-old` -> `:core:ui`. +- `:app-base` - decouple and remove. +- `:data-model` -> `:core:data-domain`. + +## Benefits +- clear module structure. +- de-coupling. +- easier navigation between modules. +- faster builds. +- reduces overall complexity. \ No newline at end of file diff --git a/docs/architecture/ADR#5 New Data Model (Done).md b/docs/architecture/ADR#5 New Data Model (Done).md new file mode 100644 index 0000000..e795440 --- /dev/null +++ b/docs/architecture/ADR#5 New Data Model (Done).md @@ -0,0 +1,34 @@ +# Architecture Decision Record (ADR) #5: New data model ✅ + +Remove `TransactionType.TRANSFER` and support only `Income` and `Expense` transctions. Represent `TRANSFER` with `TransactionBatch` which is a group of [`Transaction`]. + +## Solution + +1) Remove `TransctionType.Transfer` + +2) Create `linked_trns` table: +- id: UUID (batch id) +- linkedTrns: [UUID] + +3) Add `purpose: BatchPurpose?` to `Transaction` table: +- BatchPurpose: From | To | Fee (can be extended with more types any time) + +4) Create `BatchTrnsFlow` + +5) Simplify all `:core:actions` using `TransactionType.Transfer` + +6) Create UI for representing `TrnsBatch` as Transfer transaction card. + + +## Benefits +- support Transfer fees. +- more flexible and flexible data-model (can support taxes, etc) +- simpler business logic. +- removes `treatTransfersAsIncExp` complex settings => simpler UX. +- reduce complexity. + +## Drawbacks +- data migration might be tricky. +- Ivy Cloud won't support it. +- `TrnsBatchFlow` might be tricky. +- Deleting batched .transactions should update `linked_trns` table. \ No newline at end of file diff --git a/docs/architecture/ADR#6 Google Drive sync (Rejected).md b/docs/architecture/ADR#6 Google Drive sync (Rejected).md new file mode 100644 index 0000000..b43e1e7 --- /dev/null +++ b/docs/architecture/ADR#6 Google Drive sync (Rejected).md @@ -0,0 +1,12 @@ +# Architecture Decision Record (ADR) #6: Realm DB sync 🔴 + +Drop Ivy Cloud and any custom backend in favor of user managed backup/sync system using Google Drive. + +Bold Bet: store data using [Realm Mobile Db](https://realm.io/) and simply export/importing `.realm` DB file to and from Google Drive. + +The best thing about `.realm` is that it has cross-platform support and it'll work on Web and iOS, too. + +# Conclusion +Realm DB won't be used because it's paid and we'll lose the benefits of Room DB. We'll use JSON backups + Google Drive sync. + +**See [ADR#8 Backup and sync](ADR%238%20Backup%20and%20sync%20(Review).md).** diff --git a/docs/architecture/ADR#7 Modules Structure v1 (Done).md b/docs/architecture/ADR#7 Modules Structure v1 (Done).md new file mode 100644 index 0000000..549424b --- /dev/null +++ b/docs/architecture/ADR#7 Modules Structure v1 (Done).md @@ -0,0 +1,86 @@ +# Architecture Decision Record (ADR) #7: Modules Structure v1 ✅ + +Having more info, now we can structure and organize our modules properly. + +## :core + +Everything the app needs for implementing its "core" purpose - track income/expense & balance. + +### :core:data-model + +Domain data models designed to be used for business logic's implementation. + +### :core:domain + +Ivy Wallet's business logic. Flows, actions and functions designed to be used by features. + +### :core:persistence + +Ivy Wallet's local persistence - `Room DB` and `Datastore`. Designed to be used only +by `:core:domain` and not features. + +### :core:ui + +UI data models and components for visualing domain data. + +## :exchange-rates + +Provides latest exchange rates. + +## :design-system + +Ivy Design system containg Color Science, Typography, design language and key components. + +## :navigation + +Handles the navigation within the app. Provides a `Navigator` component for changing screens and +dependency inversion between for screen's implementation. + +## :sync + +🚧 To be defined. 🚧 + +### :sync:google-drive + +### :sync:ivy-cloud + +## :backup + +🚧 To be defined. 🚧 + +### :backup:csv + +### :backup:json-zip + +## :resources + +All resources (strings, drawable, styles) used in Ivy Wallet. See [ADR#4: Core Module](ADR%234%20Core%20Module%20(WIP).md). + +## :common + +Provides grouping for common dependency and common extensions. + +## :common:android-test + +Provides groupiing for common test instrumentation (e2e/android) test dependency and useful base classes. + +## :android + +Groups modules implementing Android SDK features together. + +### :android:billing + +Google Play Billing implementation. + +### :android:notifications + +Handling Android notifications. + +## Features grouped in modules + submodules + +All features will be grouped logically in modules with submodules. + +### :home +- :home:customer-jouney +- :home:more-menu +- :home:tab \ No newline at end of file diff --git a/docs/architecture/ADR#8 Backup and sync (WIP).md b/docs/architecture/ADR#8 Backup and sync (WIP).md new file mode 100644 index 0000000..90883a1 --- /dev/null +++ b/docs/architecture/ADR#8 Backup and sync (WIP).md @@ -0,0 +1,95 @@ +# ADR#8: Backup & Sync 🚧 + +To have a top-notch UX without the Ivy Cloud (own backend) we need to support: +- Automatic offline backups. _(mandatory)_ +- Automatic Google Drive backups. _(user's choice)_ +- Sharing between multiple users and devices. +- No lag/delays when creating transactions. + +## Solution + +### Backup Algorithm + +1) Export the database entities + user settings as one big JSON. + +``` +{ + "accounts": [ + ... + ], + "categories": [ + ... + ], + "transactions": [ + ... + ], + "settings": [ + ... + ] +} +``` + +2. Create a backup JSON file and ZIP it to reduce it's size. + +3. Saved the backup JSON zip to the local file-system + +4. _(optional)_ Upload the backup JSON to user's personal Google Drive in "/Ivy-Wallet/backups/ivy-wallet-backup-json.zip". + +:warning: When this backup must be triggered? + +**Option A): After every write to app's Room Database** +- Pros + - Google Drive backup will always be up-to-date. + - even with many transactions the backup file is <1 MB and the operation is quite fast <1 second. +- Cons + - If user accidentally deletes an account in the app, the backup file will be automatically corrupted. + - **When making multiple transactions & edits the app will consume enourmous resources!** + - The app may lag + +**Option B): WorkManager - scheduled task every X hours** +- Pros + - The app won't lag, won't consume extra resources. + - Elegant and easy to implement solution. +- Cons + - Data between backups may be lost (not a big deal). + - Might have Android background work restrictions problems. + - Will wake-up phone's CPU => consume battery. + - **No "real-time" sync if multiple users/device use a shared backup file.** + +**Option C) Work manager + trigger backup on app close or X seconds debounce after the last write to DB** +- Pros + - Elegant like Option B) + - Solves the "real-time" sync problem + - Doesn't consume much resources! +- Cons + - Harder to implement + - **The backup operation may not finish if they user closes the app before the X debounce seconds** + - Will consume more resources if the X debounce seconds are low. + +### Sync Algorithm + +Sync will only happen if the Google Drive integraiton is enabled. + +_Note: The sync must support multiple users and devices using the same backup JSON file_ + +1. App opens +2. Fetch the latest "ivy-wallet-backup-json.zip" +3. Merge the data using `ID`s and accepting as newer the ones with greatest `last_synced` in UTC _(!!! `last_synced` must be added to each item in the DB)_ + +**Actions required** +- add `last_synced` to each item that will be backuped +- don't backup user's settings because different users might want different settings + +## Architecture + +- `:drive` - drive agnostic API for mountint + CRUD files on user's personal drive, support only Google Drive (`:drive:google-drive`) for v1. + +- `:android:file-system` - Android specific logic and permission handling for reading/writing files on user's device including public directories. + +- `:backup` - exports/imports users data using JSON backup .zip (`:backup:old` imports JSON backup from the prod app) and the rest will work only with the dev verison. + - does local file-system backup + Google Drive _(user's preference)_ + - automatic backups uses user-preferred file-system location + - imports JSON backup .zip files for the old and new app + - exports a JSON backup to user's chosen location when manually requests + +- `:sync` - implement the Sync algorithm and is responsible for merging local app's Room DB + the latest backup JSON from the Drive diff --git a/docs/architecture/ADR#9 Flows Performance (Done).md b/docs/architecture/ADR#9 Flows Performance (Done).md new file mode 100644 index 0000000..ba32018 --- /dev/null +++ b/docs/architecture/ADR#9 Flows Performance (Done).md @@ -0,0 +1,101 @@ +# ADR#9: Flows Performance ✅ + +Ivy Wallet loads data turbo slow when there are many transactions. See [Issue#1719](https://github.com/Ivy-Apps/ivy-wallet/issues/1719)... 🐢 + +## Context + +- After importing ~3k transactions, the "Home" and "Account" tabs in the Main screen started to load extremly slow... +- **Note:** the same data loads instantly in the old Ivy Wallet (`prod` branch) on the PlayStore. + +## Problems + +- Ivy Wallet consumes ~220MB RAM for just showing the "Home" tab which is bad! +- After filling the available the GC starts collecting lize crazy with the one only result of setting the CPU on fire... Getting exponentially slower: +``` +Background young concurrent copying GC freed 616663(21MB) AllocSpace objects, 0(0B) LOS objects, 23% free, 62MB/81MB, paused 2.203ms,66us total 680.350ms +2023-01-12 22:35:34.451 6004-6016 vy.wallet.debug com.ivy.wallet.debug I Background concurrent copying GC freed 740158(26MB) AllocSpace objects, 0(0B) LOS objects, 28% free, 59MB/83MB, paused 86us,66us total 851.010ms +``` + +## Memory Eaters +- "Home" and "Account" tabs are in the same Compose navigation graph node => "Accounts" tab spawn many unnecessary ViewModels. **Solution: Make "Home" and "Accounts" different Navigation graph nodes** +- Unnecessary ViewModels for hidden modals (e.g. Accounts tab: CreateAccModal, CurrencyModal, IconsModal, ColorsModal, Folder modals...) **Solution: Create Modal's ViewModel only when the modal is being shown**) +- Nested flows that makes JOIN in the code. **Solution: Use Room DB Views** + +## Bottleneck + +> :warning: `combine(trns.map { trnFlow() }` can potentially spawn 3k+ flows and totally destroy our RAM... + +Note: the `prod` Ivy Wallet and the `develop` one uses the same algorithm to calculate the balance. + +Observe: `prod` runs instant, `develop` doesn't. The main difference is that `develop`'s Transaction fetches and reacts to much more things like: +- Transaction Metadata (`trn_metadata` table) +- Tags (`trn_tags` and `tags` tables) +- Attachments(`attachment` tables) + +### The Root of the problem +- `TrnsFlow` when fetching "All-time" transactions to calculate the balance. +- **Tags, TrnMetadata, Attachments:** removing this makes loading from 30s to 1-2s. +``` + private fun mapTransactionEntityFlow( + accounts: Map, + categories: Map, + trn: TrnEntity, + ): Flow? { + val account = accounts[trn.accountId.toUUID()] + ?: return null + + val trnId = trn.id + val tagsFlow = trnTagDao.findByTrnId(trnId = trnId) + .flatMapLatest { trnTags -> + tagDao.findByTagIds(tagIds = trnTags.map { it.tagId }) + } + + return combine( + trnMetadataDao.findByTrnId(trnId = trnId), + tagsFlow, + attachmentDao.findByAssociatedId(associatedId = trnId) + ) { metadataEntities, tagEntities, attachmentEntities -> + Transaction( + .... + ) + } + } +``` + +The above is done for all N transactions multiple M times (M is [3,7]) because of abstraction and inefficient algorithms. + +**Why it's called multiple times?** +- "Home tab" and "Accounts tab" load simultaneously. +- `HomeViewModel` fetches TrnsList (shown in history) and transactions twice because of the hiding transactions for the day feature. +- Other inefficiencies... hidden by our Flows abstraction. + +## Solution + +The Flows abstraction lowers complexity but hides important implementation details - the bottleneck. + +From Computer Science perspective we should fetch and use only the information that's 100% neccessary, not waste RAM and time on things that we don't need in the pipeline. + +To come-up with a solution that's both efficient and simple (elegant) we need to see the whole picture. + +That's why to make things clean we need to create `docs/algorithms` in the repo where: +- describe the steps of the algorithm +- visualize it with a **diagram** +- optimize it (in theory) +- wrap it in an elegant abstraction (wording) +- finally implement it in the code + +With `docs/algorithms` new Ivy Wallet contributors will be able to quickly understand what's happening by looking at the diagrams and also propose improvements. + + +## Quickfix + +**Problem 1: Calculating stuff (balance, income, expense)** + +0. Leave current flows as they are - don't touch them. +1. Create `optimized.CalcTrn` +2. Dao: "SELECT x,y,z... FROM transactions" instead of "SELECT *" +3. `CalcTrnsFlow` will fetch only needed trns data for calculation, no **"tags"**, **"attachemnts"** other BS => in future if more data is needed it can be added to `CalcTrn` +4. React only to what really affects the balance + +**Problem 2: Transaction history is slow when you select "All-time"** +- the same concept as Problem 1 but with `HistoryTrn` diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 0000000..e882f2b --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,74 @@ +# Architecture + +[Ivy Wallet](https://play.google.com/store/apps/details?id=com.ivy.wallet) follows the functional programming paradigm and its architecture is influenced by that fact. + +![architecture.svg](../../assets/architecture.svg) +**[--> View the diagram full-screen <--](https://raw.githubusercontent.com/Ivy-Apps/ivy-wallet/develop/assets/architecture.svg)** + +### Principles + +- Push side-effects (IO, impure code) to the edges. +- Prefer **Data** over **Calculations**. +- Prefer **Calculations** over **Actions**. +- Don't set values, **react (RX)** to changes instead. + +## Concepts + +### Data + +[ADTs (Algebraic Data Types)](https://en.wikipedia.org/wiki/Algebraic_data_type#) describing the domain as strictly as possible. In a nutshell ADTs are: +- Product types: `&&` AND _(`*` multiplication in maths)_ +```kotlin +// Product (AND) type because +// all 3 values are required +data class Person( + val firstName: String, + val lastName: String, + val age: Int +) +``` + +- Sum types: `||` OR _(`+` sum in maths)_ +```kotlin +// AppTheme can be either Light, Dark or Auto +enum class AppTheme { Light, Dark, Auto} + +// It's either Option.A containing an Int +// or Option.B which has params +sealed interface Option { + data class A(val a: Int): Option + + object B : Option +} +``` + +The idea is to define the domain using Sum (`||`) and Product (`&&`) types by combining them and constraining the types to construct only valid values. + +To see ADTs in action visit [`:core:data-model`](../../core/data-model). + +### Calculations + +Calculations are "pure" _(also known as referential transparent)_ fuctions that have no side effects. Simply said: +- When called with the same arguments, they always return the same result. +- Don't change the outside world (e.g. write to database, send data to server). +- Don't read data from the outside world (e.g. get current device time). +- Calculations don't mutate state and must not throw exceptions. + +### Actions + +Functions that have side-effects, the ones Android Devs are the most familiar with. For example, read/write to the dabase, send HTTP requests and so on. + +This code is usually hard to test and sometimes unpredictible. Avoid it. + +## UI (screens) architecture + +We follow a combination of the MVVM and MVI architectural patterns optimized to work well with [Functional Reactive Programming](https://www.toptal.com/android/functional-reactive-programming-part-1). + +- Single @Immutable `UIState` made of Compose primitives. +- Single `Event` sum type with all possible user interactions. +- [FlowViewModel](../../core/domain/src/main/java/com/ivy/core/domain/FlowViewModel.kt) producing a `Flow` of UI states and receiving a `Flow` events. +- @Composable UI functions that transform the latest `UIState` to UI and emit user interactions as `Event`. + +## ADRs + +Important and pivotal tech decisons documented as **ADRs (Architecture Decision Records)** including a context to the problem, **trade-off analysis** and a potential solution. \ No newline at end of file diff --git a/docs/archive/Business-Model.md b/docs/archive/Business-Model.md new file mode 100644 index 0000000..83b7b88 --- /dev/null +++ b/docs/archive/Business-Model.md @@ -0,0 +1,77 @@ +# Business Model + +## :warning: WARNING: this model is deprecated. See [new model](../Ivy-Apps-Business-Model.md) :warning: + +[![PRs are welcome!](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/Ivy-Apps/ivy-wallet/blob/main/CONTRIBUTING.md) +[![Feedback is welcome!](https://img.shields.io/badge/feedback-welcome-brightgreen)](https://t.me/+ETavgioAvWg4NThk) +[![Proposals are highly appreciated!](https://img.shields.io/badge/proposals-highly%20appreciated-brightgreen)](https://t.me/+ETavgioAvWg4NThk) + +**:warning: Disclaimer: This business model isn't eternal and might evolve over time. So... We need your honest opinion to make it better!** + +## Non-profit + +For now, the model behind Ivy Wallet would be non-profit. A place to develop cool tech, use it to manage your money and also build a solid public portfolio on GitHub. + +> In the near future, we might create an option in the app for PayPal donations. Donations will be used to pay the servers, Ivy's legal entity expenses and then distributed between founders and contributors. + +### Goals +- Build a cool app to use ourselves. +- More users to become contributors and supporters to the project. +- Learn, design and develop cutting-edge Android technologies while doing something meaningful, not sample projects. +- Create a public portfolio that you can proudly present. +- Earn your place in the "Hall of Fame" and make Ivy Wallet better. +- The more Ivy Wallet grows, the more what we do here will matter! +- _Hoping that one day: the Ivy Wallet app would gain enough downloads so big companies (Google, Apple, Amazon) can notice us and then we can connect our top contributors (devs, designers, marketing, product) with top job opportunities! :rocket:_ + +### We want people to proudly say.... + +- "I develop this app!" +- "I designed this feature of the app!" +- "I improved this and that!" +- "I manage this amazing product!" +- "I made this project successful and famous!" +- ... +- We want you to fill part of the project and use your skills and talents to determine its ultimate destiny! + +### Model + +```mermaid +graph TD; + +contribs(Contributors) +app(Ivy Wallet app) +reviews(Reviews) +hall("Hall of Fame") +github(GitHub repo) + +contribs -- Develop --> app +contribs -- Promote --> app +contribs -- Use --> app +contribs -- Maintain --> app + +app -- Generates --> reviews +reviews -- Traffic --> app + +app -- Displays --> hall +hall -- Portfolio --> contribs +hall -- Promote --> contribs +hall -- Respect --> contribs + +github -- Displays --> hall +``` + +### Hall of Fame +_Disclaimer: WIP... :construction:_ + +The `Hall of Fame` would be a special screen in the app to honor Ivy Wallet's contributors. + +There is a `Hall of Fame` page in our GitHub repository and right now it's called [Contributors Wall](https://github.com/Ivy-Apps/ivy-wallet#contributors-wall). + +Non-technical people who promoted, designed or helped Ivy Wallet in any way can include themselves by submitting a PR following a template. _(which will be provided here :construction:)_ + + +--- + +_Version 1.0.1_ + +_Feedback, proposals, and PRs are highly appreciated! Let's spark discussion and make Ivy Wallet and the world a better place! :rocket:_ \ No newline at end of file diff --git a/docs/archive/Ivy-Dao.md b/docs/archive/Ivy-Dao.md new file mode 100644 index 0000000..b62428f --- /dev/null +++ b/docs/archive/Ivy-Dao.md @@ -0,0 +1,35 @@ +# Ivy DAO + +## :warning: WARNING: This business model is deprecated! See [new model](../Ivy-Apps-Business-Model.md) :warning: + +## High-level picture +```mermaid +graph TD; + contribs(Contributors) + users(Users) + dao(Ivy DAO) + product(Ivy Wallet App) + + dao_dev_fund(R&D Fund) + dao_proposals(Proposals) + + tickets(GitHub Issues) + + contribs -- Develop --> product + contribs -- Design --> product + contribs -- Promote --> product + contribs -- Vote with IVY --> dao_proposals + + product -- Acquire --> users + + users -- Reviews --> product + users -- Donate crypto --> dao + + dao -- Store Donations --> dao_dev_fund + dao -- Smart Contract --> dao_proposals + dao_dev_fund -- Bounty --> dao_proposals + + dao_proposals -- If passed voting --> tickets + tickets -- Earn: Bounty + IVY --> contribs + +``` \ No newline at end of file diff --git a/docs/archive/README.md b/docs/archive/README.md new file mode 100644 index 0000000..66f849f --- /dev/null +++ b/docs/archive/README.md @@ -0,0 +1,5 @@ +# Archive + +Old-stuff... We don't need it but we still keep it here for curious people like you :) + +If you want to support our work, please give a star ⭐ to our repo. [![GitHub Repo stars](https://img.shields.io/github/stars/Ivy-Apps/ivy-wallet?style=social)](https://github.com/Ivy-Apps/ivy-wallet/stargazers) \ No newline at end of file diff --git a/docs/resources/Blog-Articles.md b/docs/resources/Blog-Articles.md new file mode 100644 index 0000000..68704c6 --- /dev/null +++ b/docs/resources/Blog-Articles.md @@ -0,0 +1,8 @@ +# Blog Articles + +## Jetpack Compose +- [Under the hood of Jetpack Compose](https://medium.com/androiddevelopers/under-the-hood-of-jetpack-compose-part-2-of-2-37b2c20c6cdd) + + +## FRP (Functional Reactive Programming) +- [Future-proof Your Android Code: Functional and Reactive Programming Foundations](https://www.toptal.com/android/functional-reactive-programming-part-1) \ No newline at end of file diff --git a/docs/resources/Books.md b/docs/resources/Books.md new file mode 100644 index 0000000..7f1028e --- /dev/null +++ b/docs/resources/Books.md @@ -0,0 +1,19 @@ +# Recommended Books 📚 + +## Software Design _(must read)_ +- **[A Philosophy of Software Design](https://www.amazon.com/Philosophy-Software-Design-John-Ousterhout/dp/1732102201)** by [John Ousterhout](https://www.google.com/search?q=john+ousterhout&oq=John+Ousterhout) +- **[Domain Modeling Made Functional](https://www.amazon.com/Domain-Modeling-Made-Functional-Domain-Driven/dp/1680502549)** by [Scott Wlaschin](https://www.google.com/search?q=Scott+Wlaschin) +- **[Grokking Simplicity: Taming complex software with functional thinking](https://www.manning.com/books/grokking-simplicity)** by [Eric Normand](https://www.google.com/search?q=eric+normand&oq=Eric+Normand) + +## Android & Kotlin _(everyday work)_ +- **[Functional Programming Ideas for the Curious Kotliner](https://leanpub.com/fp-ideas-kotlin)** by [Alejandro Serrano Mena +](https://leanpub.com/u/alejandroserrano) +- **[Kotlin Coroutines Deep](https://www.amazon.com/Kotlin-Coroutines-Deep-Marcin-Moskala/dp/8396395837)** by [Marcin Moska](https://www.google.com/search?q=marcin+moska%C5%82a&oq=Marcin+Moska%C5%82a) +- **[Jetpack Compose Internals](https://jorgecastillo.dev/book/)** by [Jorge Castillo](https://jorgecastillo.dev/) +- **[Unit Testing Principles, Practices, and Patterns](https://www.amazon.com/gp/aw/d/B09782L692/ref=tmm_kin_swatch_0?ie=UTF8&qid=&sr=)** by [Vladimir Khorikov](https://www.google.com/search?q=vladimir+khorikov) + + +## Other _(harder, won't benefit Android devs directly)_ +- **[Category Theory for Programmers](https://github.com/hmemcpy/milewski-ctfp-pdf)** by [Bartosz Milewski](https://www.google.com/search?q=Bartosz+Milewski) +- **[Designing Data-Intensive Applications](https://www.amazon.com/Designing-Data-Intensive-Applications-Reliable-Maintainable/dp/1449373321)** by [Martin Kleppmann](https://www.google.com/search?q=martin+kleppmann&oq=Martin+Kleppmann) +- **[Introduction to Algorithms, 4th edition](https://www.amazon.com/Introduction-Algorithms-fourth-Thomas-Cormen/dp/026204630X)** by [Thomas H. Cormen](https://www.google.com/search?q=thomas+h.+cormen&oq=Thomas+H.+Cormen) diff --git a/docs/resources/Docs.md b/docs/resources/Docs.md new file mode 100644 index 0000000..2298afc --- /dev/null +++ b/docs/resources/Docs.md @@ -0,0 +1,4 @@ +# Official Docs +- [Jetpack Compose](https://developer.android.com/jetpack/compose) +- [Room DB](https://developer.android.com/training/data-storage/room) +- [DataStore](https://developer.android.com/topic/libraries/architecture/datastore) \ No newline at end of file diff --git a/docs/resources/YouTube-Videos.md b/docs/resources/YouTube-Videos.md new file mode 100644 index 0000000..a0e3990 --- /dev/null +++ b/docs/resources/YouTube-Videos.md @@ -0,0 +1,10 @@ +# Recommended Videos + +## Architecture +- [A Philosophy of Software Design](https://www.youtube.com/watch?v=bmSAYlu0NcY) [Talks by Google] +- [Build a modular Android app architecture](https://www.youtube.com/watch?v=PZBg5DIzNww) [Google I/O'19] +- [Domain Modeling Made Functional](https://youtu.be/2JB1_e5wZmU) [Scott Wlaschin] + +## Kotlin Flows +- [Kotlin Flows in Practice](https://www.youtube.com/watch?v=fSB6_KE95bU) [Android Developers] +- [Asynchronous Data Streams with Kotlin Flow](https://www.youtube.com/watch?v=tYcqn48SMT8) [KotlinConf 2019] diff --git a/drive/README.md b/drive/README.md new file mode 100644 index 0000000..5646c02 --- /dev/null +++ b/drive/README.md @@ -0,0 +1,7 @@ +# Drive + +Integrations with user's personal cloud storage for now supporting only Google Drive. + +**Purpose:** +- Automatic cloud sync after the Ivy Cloud server is shut down +- Implement attachments feature where the user can upload files and photos to their transactions. \ No newline at end of file diff --git a/drive/google-drive/.gitignore b/drive/google-drive/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/drive/google-drive/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/drive/google-drive/README.md b/drive/google-drive/README.md new file mode 100644 index 0000000..a782607 --- /dev/null +++ b/drive/google-drive/README.md @@ -0,0 +1,3 @@ +# Google Drive + +Google Drive API integration so we can CRUD files in user's personal drive with the purpose of implementing cloud sync and attachments features. \ No newline at end of file diff --git a/drive/google-drive/build.gradle.kts b/drive/google-drive/build.gradle.kts new file mode 100644 index 0000000..b75c7cf --- /dev/null +++ b/drive/google-drive/build.gradle.kts @@ -0,0 +1,32 @@ +import com.ivy.buildsrc.Google +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Testing +import com.ivy.buildsrc.Timber + +apply() + +plugins { + `android-library` +} + +dependencies { + implementation(project(":common:main")) + implementation(project(":android:common")) + + // region Google Drive deps + // TODO: Extract to "dependencies.gradle.kts" in buildSrc + implementation("com.google.apis:google-api-services-drive:v3-rev136-1.25.0") { + exclude(group = "com.google.guava", module = "listenablefuture") + } + implementation("com.google.http-client:google-http-client-gson:1.26.0") + implementation("com.google.api-client:google-api-client-android:1.26.0") { + exclude(group = "com.google.guava", module = "listenablefuture") + } + implementation("com.google.guava:guava:28.1-android") + // endregion + + Hilt() + Google() + Timber(api = true) + Testing() +} \ No newline at end of file diff --git a/drive/google-drive/src/main/AndroidManifest.xml b/drive/google-drive/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f99a478 --- /dev/null +++ b/drive/google-drive/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/drive/google-drive/src/main/java/com/ivy/drive/google_drive/DriveInstance.kt b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/DriveInstance.kt new file mode 100644 index 0000000..6909d2d --- /dev/null +++ b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/DriveInstance.kt @@ -0,0 +1,31 @@ +package com.ivy.drive.google_drive + +import android.content.Context +import arrow.core.Either +import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.api.client.extensions.android.http.AndroidHttp +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential +import com.google.api.client.json.jackson2.JacksonFactory +import com.google.api.services.drive.Drive +import com.google.api.services.drive.DriveScopes +import com.ivy.drive.google_drive.data.GoogleDriveError + +private const val APP_NAME = "Ivy Wallet" + +internal fun driveInstance( + context: Context, + googleAccount: GoogleSignInAccount +): Either = Either.catch { + // Use the authenticated account to sign in to the Drive service. + val credential = GoogleAccountCredential.usingOAuth2( + context, listOf(DriveScopes.DRIVE_FILE) + ) + credential.selectedAccount = googleAccount.account + Drive.Builder( + AndroidHttp.newCompatibleTransport(), + JacksonFactory.getDefaultInstance(), + credential + ) + .setApplicationName(APP_NAME) + .build() +}.mapLeft(GoogleDriveError::NotMounted) \ No newline at end of file diff --git a/drive/google-drive/src/main/java/com/ivy/drive/google_drive/GoogleDriveConnectionImpl.kt b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/GoogleDriveConnectionImpl.kt new file mode 100644 index 0000000..72927ff --- /dev/null +++ b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/GoogleDriveConnectionImpl.kt @@ -0,0 +1,50 @@ +package com.ivy.drive.google_drive + +import android.content.Context +import androidx.appcompat.app.AppCompatActivity +import arrow.core.Either +import arrow.core.left +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.api.services.drive.Drive +import com.ivy.drive.google_drive.api.GoogleDriveConnection +import com.ivy.drive.google_drive.data.GoogleDriveError +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class GoogleDriveConnectionImpl @Inject constructor( + @ApplicationContext + private val context: Context, + private val mountDriveLauncher: MountDriveLauncher +) : GoogleDriveConnection, GoogleDriveProvider { + private val _isMounted = MutableStateFlow(false) + override val driveMounted: StateFlow = _isMounted + + override var errorOrDrive: Either = + GoogleDriveError.NotMounted(IllegalStateException("Drive not mounted")).left() + + override fun wire(activity: AppCompatActivity) = mountDriveLauncher.wire(activity) + + override fun connect() = mountDriveLauncher.launch(Unit) { drive -> + mountInternal(drive) + } + + override suspend fun mount() { + try { + GoogleSignIn.getLastSignedInAccount(context)?.let { googleAccount -> + val drive = driveInstance(context, googleAccount) + mountInternal(drive) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + private fun mountInternal(drive: Either) { + this.errorOrDrive = drive + _isMounted.value = drive.isRight() + } +} \ No newline at end of file diff --git a/drive/google-drive/src/main/java/com/ivy/drive/google_drive/GoogleDriveProvider.kt b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/GoogleDriveProvider.kt new file mode 100644 index 0000000..50bc2ac --- /dev/null +++ b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/GoogleDriveProvider.kt @@ -0,0 +1,9 @@ +package com.ivy.drive.google_drive + +import arrow.core.Either +import com.google.api.services.drive.Drive +import com.ivy.drive.google_drive.data.GoogleDriveError + +internal interface GoogleDriveProvider { + val errorOrDrive: Either +} \ No newline at end of file diff --git a/drive/google-drive/src/main/java/com/ivy/drive/google_drive/GoogleDriveServiceImpl.kt b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/GoogleDriveServiceImpl.kt new file mode 100644 index 0000000..8ed754d --- /dev/null +++ b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/GoogleDriveServiceImpl.kt @@ -0,0 +1,218 @@ +package com.ivy.drive.google_drive + + +import arrow.core.Either +import arrow.core.NonEmptyList +import arrow.core.None +import arrow.core.Some +import arrow.core.computations.either +import com.google.api.client.http.ByteArrayContent +import com.google.api.services.drive.Drive +import com.google.api.services.drive.model.File +import com.ivy.drive.google_drive.api.GoogleDriveService +import com.ivy.drive.google_drive.data.DriveMimeType +import com.ivy.drive.google_drive.data.GoogleDriveError +import com.ivy.drive.google_drive.data.GoogleDriveFileId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.file.Path +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class GoogleDriveServiceImpl @Inject constructor( + private val googleDriveProvider: GoogleDriveProvider +) : GoogleDriveService, GoogleDriveProvider by googleDriveProvider { + override fun isMounted(): Boolean = errorOrDrive.isRight() + + override suspend fun read(path: Path): Either = either { + fetchFileFromPath(path).bind()?.let { file -> + fetchFileContentsById(GoogleDriveFileId(file.id)).bind() + } + } + + override suspend fun write( + path: Path, + content: ByteArray, + mimeType: DriveMimeType + ): Either = either { + val maybeFile = fetchFileFromPath(path).bind() + if (maybeFile == null) { + createFileAndDirectoryStructure(path, content, mimeType).bind() + } else { + updateFile( + ByteArrayContent(mimeType.value, content), + GoogleDriveFileId(maybeFile.id) + ).bind() + + } + } + + override suspend fun delete(path: Path): Either = either { + fetchFileFromPath(path).bind()?.let { file -> + deleteFileById(GoogleDriveFileId(file.id)).bind() + } + } + + private suspend fun createFileAndDirectoryStructure( + path: Path, + content: ByteArray, + mimeType: DriveMimeType + ) = either { + val fileName = path.last().toString() + val directories = path.toList().dropLast(1) + when (val directoryList = NonEmptyList.fromList(directories)) { + None -> createFile(fileName, content, mimeType).bind() + is Some -> { + val directoryId = createDirectoryTree(directoryList.value).bind() + createFile(fileName, content, mimeType, directoryId).bind() + } + } + } + + private suspend fun deleteFileById(fileId: GoogleDriveFileId): Either = + either { + val drive = errorOrDrive.bind() + Either.catch { + withContext(Dispatchers.IO) { + drive.files().delete(fileId.id).execute() + } + }.mapLeft { GoogleDriveError.IOError(it) }.bind() + } + + private suspend fun fetchFileContentsById(fileId: GoogleDriveFileId) + : Either = either { + val drive = errorOrDrive.bind() + withContext(Dispatchers.IO) { + Either.catch { + drive.files().get(fileId.id).executeMediaAsInputStream().readBytes() + }.mapLeft { GoogleDriveError.IOError(it) } + }.bind() + } + + private suspend fun updateFile( + content: ByteArrayContent, + fileId: GoogleDriveFileId + ): Either = either { + val drive = errorOrDrive.bind() + Either.catch { + withContext(Dispatchers.IO) { + drive.files().update(fileId.id, null, content).execute() + } + }.mapLeft { GoogleDriveError.IOError(it) }.bind() + } + + private suspend fun fetchFileFromPath(path: Path): Either { + suspend fun fetchFileFromPathHelper( + pathAsList: List, + parentId: GoogleDriveFileId? = null + ): Either = + either { + if (pathAsList.size <= 1) { + val fileName = pathAsList.first() + fetchNode(fileName, parentId).bind() + } else { + val directoryName = pathAsList.first() + val directory = fetchNode(directoryName, parentId).bind() + ?: return@either null // a required directory in the path is missing + // => the file is not found + fetchFileFromPathHelper( + pathAsList = pathAsList.drop(1), + parentId = directory.id?.let(::GoogleDriveFileId) + ).bind() + } + } + return fetchFileFromPathHelper(path.toList().map { it.toString() }) + } + + + private suspend fun fetchNode( + fileName: String, + parentId: GoogleDriveFileId? = null + ): Either = either { + val drive = errorOrDrive.bind() + Either.catch({ GoogleDriveError.IOError(it) }) { + val querySuffix = parentId?.let { " and '${it.id}' in parents" } ?: "" + val query = "name = '$fileName'$querySuffix" + queryFiles(drive, query) + }.bind()?.firstOrNull() + } + + private suspend fun queryFiles( + drive: Drive, + query: String + ): List? = withContext(Dispatchers.IO) { + drive.files().list().apply { + q = query + }.execute()?.files + } + + private suspend fun createFile( + fileName: String, + bytes: ByteArray, + mimeType: DriveMimeType, + parentId: GoogleDriveFileId? = null + ): Either = insertNode( + nodeName = fileName, + parentId = parentId, + content = ByteArrayContent(mimeType.value, bytes) + ) + + + private suspend fun insertNode( + nodeName: String, + directory: Boolean = false, + parentId: GoogleDriveFileId? = null, + content: ByteArrayContent? = null + ): Either = either { + val drive = errorOrDrive.bind() + withContext(Dispatchers.IO) { + val metadata = createMetadata(nodeName, directory, parentId) + Either.catch { + val files = drive.files() + val create = if (content == null) { + // WARNING: the create() without media content must be used for folders + files.create(metadata) + } else { + // This cannot create folders!! Only for files + files.create(metadata, content) + } + create.execute() + }.mapLeft { GoogleDriveError.IOError(it) } + }.bind() + } + + private fun createMetadata( + nodeName: String, + directory: Boolean, + parentId: GoogleDriveFileId? + ): File = File().apply { + name = nodeName + if (directory) { + mimeType = DriveMimeType.FOLDER.value + } + if (parentId != null) { + parents = listOf(parentId.id) + } + } + + private suspend fun createDirectoryTree( + directories: NonEmptyList, + parentId: GoogleDriveFileId? = null + ): Either = + either { + val name = directories.head.toString() + val node = fetchNode(name, parentId).bind() ?: insertNode( + nodeName = name, + directory = true, + parentId = parentId + ).bind() + val nextParentId = GoogleDriveFileId(node.id) + val remainingDirectories = directories.drop(1) + when (val nelRemainingDirectories = NonEmptyList.fromList(remainingDirectories)) { + is Some -> createDirectoryTree(nelRemainingDirectories.value, nextParentId).bind() + None -> nextParentId + } + } + +} \ No newline at end of file diff --git a/drive/google-drive/src/main/java/com/ivy/drive/google_drive/MountDriveLauncher.kt b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/MountDriveLauncher.kt new file mode 100644 index 0000000..cacaeb1 --- /dev/null +++ b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/MountDriveLauncher.kt @@ -0,0 +1,47 @@ +package com.ivy.drive.google_drive + +import android.content.Context +import android.content.Intent +import arrow.core.Either +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.common.api.Scope +import com.google.android.gms.tasks.Task +import com.google.api.services.drive.Drive +import com.google.api.services.drive.DriveScopes +import com.ivy.android.common.ActivityLauncher +import com.ivy.drive.google_drive.data.GoogleDriveError +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +internal class MountDriveLauncher @Inject constructor( + @ApplicationContext + private val appContext: Context, +) : ActivityLauncher>() { + override fun intent(context: Context, input: Unit): Intent { + val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestEmail() + .requestProfile() + .requestIdToken("364763737033-t1d2qe7s0s8597k7anu3sb2nq79ot5tp.apps.googleusercontent.com") + .requestScopes(Scope(DriveScopes.DRIVE_FILE)) + .build() + return GoogleSignIn.getClient(context, signInOptions).signInIntent + } + + override fun onActivityResult( + resultCode: Int, + intent: Intent? + ): Either = + try { + val task: Task = + GoogleSignIn.getSignedInAccountFromIntent(intent) + val account: GoogleSignInAccount = task.getResult(ApiException::class.java) + driveInstance(context = appContext, googleAccount = account) + } catch (e: ApiException) { + e.printStackTrace() + Either.Left(GoogleDriveError.NotMounted(e)) + } + +} \ No newline at end of file diff --git a/drive/google-drive/src/main/java/com/ivy/drive/google_drive/api/GoogleDriveConnection.kt b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/api/GoogleDriveConnection.kt new file mode 100644 index 0000000..be8ba23 --- /dev/null +++ b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/api/GoogleDriveConnection.kt @@ -0,0 +1,25 @@ +package com.ivy.drive.google_drive.api + +import androidx.appcompat.app.AppCompatActivity +import kotlinx.coroutines.flow.StateFlow + +interface GoogleDriveConnection { + /** + * Call in Activity's onCreate to register ActivityResultLauncher + */ + fun wire(activity: AppCompatActivity) + + /** + * Prompts the user to login with Google and mounts the drive + */ + fun connect() + + /** + * Mounts drive if it's connected + */ + suspend fun mount() + + // TODO: Instead of boolean return Option which + // contains the email of the mounted drive so it can be displayed in the UI + val driveMounted: StateFlow +} \ No newline at end of file diff --git a/drive/google-drive/src/main/java/com/ivy/drive/google_drive/api/GoogleDriveService.kt b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/api/GoogleDriveService.kt new file mode 100644 index 0000000..d4575d2 --- /dev/null +++ b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/api/GoogleDriveService.kt @@ -0,0 +1,30 @@ +package com.ivy.drive.google_drive.api + +import arrow.core.Either +import arrow.core.computations.either +import com.ivy.drive.google_drive.data.DriveMimeType +import com.ivy.drive.google_drive.data.GoogleDriveError +import java.nio.file.Path + +interface GoogleDriveService { + fun isMounted(): Boolean + + suspend fun read(path: Path): Either + + suspend fun readAsString(myPath: Path): Either = either { + read(myPath).bind()?.decodeToString() + } + + suspend fun write( + path: Path, + content: ByteArray, + mimeType: DriveMimeType + ): Either + + suspend fun write(path: Path, content: String): Either = + write(path, content.toByteArray(), DriveMimeType.TXT) + + suspend fun delete(path: Path): Either +} + + diff --git a/drive/google-drive/src/main/java/com/ivy/drive/google_drive/data/DriveMimeType.kt b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/data/DriveMimeType.kt new file mode 100644 index 0000000..d8fe035 --- /dev/null +++ b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/data/DriveMimeType.kt @@ -0,0 +1,12 @@ +package com.ivy.drive.google_drive.data + +enum class DriveMimeType(val value: String) { + PDF("application/pdf"), + TXT("text/plain"), + JPEG("image/jpeg"), + PNG("image/png"), + SVG("image/svg+xml"), + CSV("text/csv"), + ZIP("application/zip"), + FOLDER("application/vnd.google-apps.folder"), +} \ No newline at end of file diff --git a/drive/google-drive/src/main/java/com/ivy/drive/google_drive/data/GoogleDriveError.kt b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/data/GoogleDriveError.kt new file mode 100644 index 0000000..53de7f4 --- /dev/null +++ b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/data/GoogleDriveError.kt @@ -0,0 +1,9 @@ +package com.ivy.drive.google_drive.data + +sealed interface GoogleDriveError { + + val exception: Throwable + + data class NotMounted(override val exception: Throwable) : GoogleDriveError + data class IOError(override val exception: Throwable) : GoogleDriveError +} \ No newline at end of file diff --git a/drive/google-drive/src/main/java/com/ivy/drive/google_drive/data/GoogleDriveFileId.kt b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/data/GoogleDriveFileId.kt new file mode 100644 index 0000000..f6ed66a --- /dev/null +++ b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/data/GoogleDriveFileId.kt @@ -0,0 +1,4 @@ +package com.ivy.drive.google_drive.data + +@JvmInline +value class GoogleDriveFileId(val id: String) \ No newline at end of file diff --git a/drive/google-drive/src/main/java/com/ivy/drive/google_drive/di/GoogleDriveModuleDI.kt b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/di/GoogleDriveModuleDI.kt new file mode 100644 index 0000000..ec73069 --- /dev/null +++ b/drive/google-drive/src/main/java/com/ivy/drive/google_drive/di/GoogleDriveModuleDI.kt @@ -0,0 +1,24 @@ +package com.ivy.drive.google_drive.di + +import com.ivy.drive.google_drive.GoogleDriveConnectionImpl +import com.ivy.drive.google_drive.GoogleDriveProvider +import com.ivy.drive.google_drive.GoogleDriveServiceImpl +import com.ivy.drive.google_drive.api.GoogleDriveConnection +import com.ivy.drive.google_drive.api.GoogleDriveService +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class GoogleDriveModuleDI { + @Binds + internal abstract fun googleDriveService(impl: GoogleDriveServiceImpl): GoogleDriveService + + @Binds + internal abstract fun googleDriveInitializer(impl: GoogleDriveConnectionImpl): GoogleDriveConnection + + @Binds + internal abstract fun googleDriveProvider(impl: GoogleDriveConnectionImpl): GoogleDriveProvider +} \ No newline at end of file diff --git a/exchange-rates/build.gradle.kts b/exchange-rates/build.gradle.kts new file mode 100644 index 0000000..a03e03b --- /dev/null +++ b/exchange-rates/build.gradle.kts @@ -0,0 +1,21 @@ +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":design-system")) + implementation(project(":core:domain")) + implementation(project(":core:ui")) + implementation(project(":core:data-model")) + implementation(project(":core:persistence")) + implementation(project(":common:main")) + Testing() + +} \ No newline at end of file diff --git a/exchange-rates/src/main/AndroidManifest.xml b/exchange-rates/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4a0daf7 --- /dev/null +++ b/exchange-rates/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/exchange-rates/src/main/java/com/ivy/exchangeRates/ExchangeRatesScreen.kt b/exchange-rates/src/main/java/com/ivy/exchangeRates/ExchangeRatesScreen.kt new file mode 100644 index 0000000..cf2ea0e --- /dev/null +++ b/exchange-rates/src/main/java/com/ivy/exchangeRates/ExchangeRatesScreen.kt @@ -0,0 +1,207 @@ +package com.ivy.exchangeRates + +import androidx.compose.foundation.layout.BoxWithConstraintsScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ivy.core.ui.amount.AmountModal +import com.ivy.data.Value +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.ColumnRoot +import com.ivy.design.l1_buildingBlocks.DividerW +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.input.InputFieldType +import com.ivy.design.l2_components.input.IvyInputField +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview +import com.ivy.exchangeRates.component.RateItem +import com.ivy.exchangeRates.data.RateUi +import com.ivy.exchangeRates.modal.AddRateModal + + +@Composable +fun BoxWithConstraintsScope.ExchangeRatesScreen() { + val viewModel: ExchangeRatesViewModel = hiltViewModel() + val state by viewModel.uiState.collectAsState() + + UI( + state = state, + onEvent = viewModel::onEvent + ) +} + +@Composable +private fun BoxWithConstraintsScope.UI( + state: RatesState, + onEvent: (RatesEvent) -> Unit, +) { + + val amountModal = rememberIvyModal() + val addRateModal = rememberIvyModal() + var rateToUpdate by remember { + mutableStateOf(null) + } + val onRateClick = { rate: RateUi -> + rateToUpdate = rate + amountModal.show() + } + + ColumnRoot { + SpacerVer(height = 16.dp) + + IvyInputField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + iconLeft=R.drawable.ic_search, + type = InputFieldType.SingleLine, + initialValue = "", + placeholder = "Search Currency", + onValueChange = { + onEvent(RatesEvent.Search(it)) + } + ) + SpacerVer(height = 4.dp) + LazyColumn { + ratesSection(text = "Manual") + items( + items = state.manual, + key = { "${it.from}-${it.to}" } + ) { rate -> + SpacerVer(height = 4.dp) + RateItem( + rate = rate, + onDelete = { onEvent(RatesEvent.RemoveOverride(rate)) }, + onClick = { onRateClick(rate) } + ) + } + ratesSection(text = "Automatic") + items( + items = state.automatic, + key = { "${it.from}-${it.to}" } + ) { rate -> + SpacerVer(height = 4.dp) + RateItem( + rate = rate, + onDelete = null, + onClick = { onRateClick(rate) }, + ) + } + item(key = "last_item_spacer") { + SpacerVer(height = 480.dp) + } + } + } + + + IvyButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 28.dp), + size = ButtonSize.Small, + feeling = Feeling.Positive, + visibility = Visibility.High, + text = "Add rate" + ) { + addRateModal.show() + } + + AddRateModal( + modal = addRateModal, + baseCurrency = state.baseCurrency, + dismiss = { + addRateModal.hide() + }, + onAdd = { toCurrency, exchangeRate -> + onEvent( + RatesEvent.AddRate( + RateUi( + from = state.baseCurrency, + to = toCurrency, + rate = exchangeRate + ) + ) + ) + } + ) + + AmountModal( + modal = amountModal, + initialAmount = Value(rateToUpdate?.rate ?: 0.0, ""), + onAmountEnter = { newRate -> + rateToUpdate?.let { + onEvent(RatesEvent.UpdateRate(rateToUpdate!!, newRate.amount)) + } + } + ) +} + +private fun LazyListScope.ratesSection( + text: String +) { + item{ + SpacerVer(height = 24.dp) + Row(verticalAlignment = Alignment.CenterVertically) { + DividerW() + SpacerHor(width = 16.dp) + Text( + text = text, + style = UI.typo.h2 + ) + SpacerHor(width = 16.dp) + DividerW() + } + } +} + + +@Preview +@Composable +private fun Preview() { + IvyPreview { + UI( + state = RatesState( + baseCurrency = "BGN", + manual = listOf( + RateUi("BGN", "USD", 1.85), + RateUi("BGN", "EUR", 1.96), + ), + automatic = listOf( + RateUi("XXX", "YYY", 1.23), + RateUi("XXX", "YYY", 1.23), + RateUi("XXX", "YYY", 1.23), + RateUi("XXX", "YYY", 1.23), + RateUi("XXX", "YYY", 1.23), + RateUi("XXX", "YYY", 1.23), + RateUi("XXX", "YYY", 1.23), + RateUi("XXX", "YYY", 1.23), + RateUi("XXX", "YYY", 1.23), + RateUi("XXX", "YYY", 1.23), + RateUi("XXX", "YYY", 1.23), + RateUi("XXX", "YYY", 1.23), + ) + ), + onEvent = {} + ) + } +} \ No newline at end of file diff --git a/exchange-rates/src/main/java/com/ivy/exchangeRates/ExchangeRatesViewModel.kt b/exchange-rates/src/main/java/com/ivy/exchangeRates/ExchangeRatesViewModel.kt new file mode 100644 index 0000000..b8f763e --- /dev/null +++ b/exchange-rates/src/main/java/com/ivy/exchangeRates/ExchangeRatesViewModel.kt @@ -0,0 +1,107 @@ +package com.ivy.exchangeRates + +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.persistence.entity.exchange.ExchangeRateOverrideEntity +import com.ivy.data.SyncState +import com.ivy.exchangeRates.action.RemoveOverriddenRateAct +import com.ivy.exchangeRates.action.WriteOverriddenRateAct +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import java.time.Instant +import javax.inject.Inject + + +@HiltViewModel +class ExchangeRatesViewModel @Inject constructor( + private val removeOverriddenRateAct: RemoveOverriddenRateAct, + private val writeOverriddenRateAct: WriteOverriddenRateAct, + private val ratesFlow: RatesStateFlow +) : SimpleFlowViewModel() { + + override val initialUi = RatesState( + baseCurrency = "", + manual = emptyList(), + automatic = emptyList() + ) + private val searchQuery = MutableStateFlow("") + + override val uiFlow: Flow = combine( + ratesFlow(), searchQuery + ) { ratesState, query -> + if (query.isNotBlank()) { + RatesState( + baseCurrency = ratesState.baseCurrency, + manual = ratesState.manual.filter { + it.to.contains(query, true) + }, + automatic = ratesState.automatic.filter { + it.to.contains(query, true) + } + ) + + } else { + ratesState + } + } + + + override suspend fun handleEvent(event: RatesEvent) { + when (event) { + is RatesEvent.RemoveOverride -> { + handleRemoveOverride(event) + } + + is RatesEvent.Search -> { + searchQuery.value = event.query.trim() + } + + is RatesEvent.UpdateRate -> { + handleUpdateRate(event) + } + + is RatesEvent.AddRate -> { + handleAddRate(event) + } + } + } + + private suspend fun handleRemoveOverride(event: RatesEvent.RemoveOverride) { + removeOverriddenRateAct( + RemoveOverriddenRateAct.Input( + baseCurrency = event.rate.from, + currency = event.rate.to + ) + ) + } + + private suspend fun handleUpdateRate(event: RatesEvent.UpdateRate) { + if (event.newRate > 0.0) { + writeOverriddenRateAct( + ExchangeRateOverrideEntity( + baseCurrency = event.rate.from, + currency = event.rate.to, + rate = event.newRate, + sync = SyncState.Synced, + lastUpdated = Instant.MIN + ) + ) + } + } + + private suspend fun handleAddRate(event: RatesEvent.AddRate) { + if (event.rate.rate > 0.0) { + writeOverriddenRateAct( + ExchangeRateOverrideEntity( + baseCurrency = event.rate.from, + currency = event.rate.to, + rate = event.rate.rate, + sync = SyncState.Synced, + lastUpdated = Instant.MIN + ) + ) + } + } + +} \ No newline at end of file diff --git a/exchange-rates/src/main/java/com/ivy/exchangeRates/RatesEvent.kt b/exchange-rates/src/main/java/com/ivy/exchangeRates/RatesEvent.kt new file mode 100644 index 0000000..6de8964 --- /dev/null +++ b/exchange-rates/src/main/java/com/ivy/exchangeRates/RatesEvent.kt @@ -0,0 +1,10 @@ +package com.ivy.exchangeRates + +import com.ivy.exchangeRates.data.RateUi + +sealed interface RatesEvent{ + data class Search(val query: String) : RatesEvent + data class RemoveOverride(val rate: RateUi) : RatesEvent + data class UpdateRate(val rate: RateUi, val newRate: Double) : RatesEvent + data class AddRate(val rate: RateUi) : RatesEvent +} \ No newline at end of file diff --git a/exchange-rates/src/main/java/com/ivy/exchangeRates/RatesState.kt b/exchange-rates/src/main/java/com/ivy/exchangeRates/RatesState.kt new file mode 100644 index 0000000..5b91979 --- /dev/null +++ b/exchange-rates/src/main/java/com/ivy/exchangeRates/RatesState.kt @@ -0,0 +1,12 @@ +package com.ivy.exchangeRates + +import androidx.compose.runtime.Immutable +import com.ivy.exchangeRates.data.RateUi + +//Represents the state of exchange rates screen +@Immutable +data class RatesState( + val baseCurrency: String, + val manual: List, + val automatic: List +) \ No newline at end of file diff --git a/exchange-rates/src/main/java/com/ivy/exchangeRates/RatesStateFlow.kt b/exchange-rates/src/main/java/com/ivy/exchangeRates/RatesStateFlow.kt new file mode 100644 index 0000000..515d4cb --- /dev/null +++ b/exchange-rates/src/main/java/com/ivy/exchangeRates/RatesStateFlow.kt @@ -0,0 +1,62 @@ +package com.ivy.exchangeRates + +import com.ivy.exchangeRates.data.RateUi +import com.ivy.core.domain.action.SharedFlowAction +import com.ivy.core.domain.action.settings.basecurrency.BaseCurrencyFlow +import com.ivy.core.persistence.algorithm.calc.Rate +import com.ivy.core.persistence.algorithm.calc.RatesDao +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject +import javax.inject.Singleton + +//returns flow of RatesState which contains manual and automatic exchangeRates list +@Singleton +class RatesStateFlow @Inject constructor( + private val baseCurrencyFlow: BaseCurrencyFlow, + private val ratesDao: RatesDao +) : SharedFlowAction() { + override fun initialValue(): RatesState = RatesState( + baseCurrency = "", + manual = emptyList(), + automatic = emptyList() + ) + + + @OptIn(ExperimentalCoroutinesApi::class) + override fun createFlow(): Flow = baseCurrencyFlow() + .flatMapLatest { baseCurrency -> + if (baseCurrency.isBlank()) { + flowOf(initialValue()) + } else { + combine( + ratesDao.findAll(baseCurrency), + ratesDao.findAllOverrides(baseCurrency) + ) { rates, overrides -> + //converting list to set to improve efficiency of contains() + val manual = overrides.map { it.currency }.toSet() + RatesState( + baseCurrency = baseCurrency, + manual = overrides.map { + toRateUi(baseCurrency, it) + }, + automatic = rates.filter { + !manual.contains(it.currency) + }.map { + toRateUi(baseCurrency, it) + } + ) + } + } + } + + private fun toRateUi(baseCurrency: String, rate: Rate) = RateUi( + from = baseCurrency, + to = rate.currency, + rate = rate.rate + ) + +} \ No newline at end of file diff --git a/exchange-rates/src/main/java/com/ivy/exchangeRates/action/RemoveOverriddenRateAct.kt b/exchange-rates/src/main/java/com/ivy/exchangeRates/action/RemoveOverriddenRateAct.kt new file mode 100644 index 0000000..5b9d7ae --- /dev/null +++ b/exchange-rates/src/main/java/com/ivy/exchangeRates/action/RemoveOverriddenRateAct.kt @@ -0,0 +1,23 @@ +package com.ivy.exchangeRates.action + +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.dao.exchange.ExchangeRateOverrideDao +import javax.inject.Inject + +//Action to delete items from exchange_rates_override table +class RemoveOverriddenRateAct @Inject constructor( + private val exchangeRatesOverrideDao: ExchangeRateOverrideDao, +) : Action() { + + data class Input( + val baseCurrency: String, + val currency: String + ) + + override suspend fun action(input: Input) { + exchangeRatesOverrideDao.deleteByBaseCurrencyAndCurrency( + baseCurrency = input.baseCurrency, + currency = input.currency + ) + } +} \ No newline at end of file diff --git a/exchange-rates/src/main/java/com/ivy/exchangeRates/action/WriteOverriddenRateAct.kt b/exchange-rates/src/main/java/com/ivy/exchangeRates/action/WriteOverriddenRateAct.kt new file mode 100644 index 0000000..4445063 --- /dev/null +++ b/exchange-rates/src/main/java/com/ivy/exchangeRates/action/WriteOverriddenRateAct.kt @@ -0,0 +1,18 @@ +package com.ivy.exchangeRates.action + +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.dao.exchange.ExchangeRateOverrideDao +import com.ivy.core.persistence.entity.exchange.ExchangeRateOverrideEntity +import javax.inject.Inject + +//Action to add items to exchange_rates_override table +class WriteOverriddenRateAct @Inject constructor( + private val exchangeRatesOverrideDao: ExchangeRateOverrideDao, +) : Action() { + + override suspend fun action(input: ExchangeRateOverrideEntity) { + exchangeRatesOverrideDao.save( + listOf(input) + ) + } +} \ No newline at end of file diff --git a/exchange-rates/src/main/java/com/ivy/exchangeRates/component/RateItem.kt b/exchange-rates/src/main/java/com/ivy/exchangeRates/component/RateItem.kt new file mode 100644 index 0000000..5cad492 --- /dev/null +++ b/exchange-rates/src/main/java/com/ivy/exchangeRates/component/RateItem.kt @@ -0,0 +1,90 @@ +package com.ivy.exchangeRates.component + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.B1 +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerWeight +import com.ivy.design.l3_ivyComponents.button.DeleteButton +import com.ivy.design.util.IvyPreview +import com.ivy.exchangeRates.data.RateUi +import kotlin.math.round + + +//Individual Rate item composable for the main ExchangeRate screen +@Composable +fun RateItem( + rate: RateUi, + onDelete: (() -> Unit)?, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, UI.colors.primary) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + B1( + text = "${rate.from}-${rate.to}:", + fontWeight = FontWeight.Medium + ) + SpacerHor(width = 8.dp) + B1( + //rounding the rate value to 5 decimal points + text = "%.5f".format(round(rate.rate * 100000) / 100000), + fontWeight = FontWeight.SemiBold + ) + if (onDelete != null) { + SpacerWeight(weight = 1f) + DeleteButton(onClick = onDelete) + } + } +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + RateItem( + rate = RateUi( + from = "BGN", + to = "EUR", + rate = 1.95583 + ), + onDelete = null, + onClick = {} + ) + } +} + +@Preview +@Composable +private fun Preview_Delete() { + IvyPreview { + RateItem( + rate = RateUi( + from = "BGN", + to = "EUR", + rate = 1.95583 + ), + onDelete = { }, + onClick = {} + ) + } +} + +// endregion \ No newline at end of file diff --git a/exchange-rates/src/main/java/com/ivy/exchangeRates/data/RateUi.kt b/exchange-rates/src/main/java/com/ivy/exchangeRates/data/RateUi.kt new file mode 100644 index 0000000..9b7eb5f --- /dev/null +++ b/exchange-rates/src/main/java/com/ivy/exchangeRates/data/RateUi.kt @@ -0,0 +1,10 @@ +package com.ivy.exchangeRates.data + +import androidx.compose.runtime.Immutable + +@Immutable +data class RateUi( + val from: String, + val to: String, + val rate: Double +) \ No newline at end of file diff --git a/exchange-rates/src/main/java/com/ivy/exchangeRates/modal/AddRateModal.kt b/exchange-rates/src/main/java/com/ivy/exchangeRates/modal/AddRateModal.kt new file mode 100644 index 0000000..8836500 --- /dev/null +++ b/exchange-rates/src/main/java/com/ivy/exchangeRates/modal/AddRateModal.kt @@ -0,0 +1,107 @@ +package com.ivy.exchangeRates.modal + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.BoxWithConstraintsScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.OutlinedTextField +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.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.amount.AmountModal +import com.ivy.data.Value +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.Orange +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l1_buildingBlocks.Text +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.DynamicSave +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.util.IvyPreview + + +@Composable +fun BoxWithConstraintsScope.AddRateModal( + modal: IvyModal, + baseCurrency: String, + dismiss: () -> Unit, + onAdd: (toCurrency: String, exchangeRate: Double) -> Unit, +) { + var toCurrency by remember { mutableStateOf("") } + val amountModal = rememberIvyModal() + var rate by remember { mutableStateOf(null) } + + Modal( + modal = modal, + actions = { + DynamicSave(item = null) { + val to = toCurrency + val finalRate = rate + if (to.isNotBlank() && finalRate != null) { + onAdd(to, finalRate) + dismiss() + } + } + } + ) { + SpacerVer(height = 16.dp) + Title(text = "Add rate") + SpacerVer(height = 24.dp) + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + value = toCurrency, + label = { + Text(text = "Currency", typo = UI.typo.b2) + }, + onValueChange = { toCurrency = it }, + ) + SpacerVer(height = 12.dp) + Text( + modifier = Modifier + .fillMaxWidth() + .clickable { + amountModal.show() + } + .padding(horizontal = 16.dp, vertical = 12.dp), + text = "${baseCurrency}-${toCurrency} = ${rate ?: "???"}", + typo = UI.typo.h2, + color = Orange, + textAlign = TextAlign.Center, + ) + SpacerVer(height = 24.dp) + } + + + AmountModal( + modal = amountModal, + initialAmount = Value(rate ?: 0.0, ""), + onAmountEnter = { newRate -> + rate = newRate.amount + } + ) +} + + +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = rememberIvyModal() + AddRateModal( + modal = modal, + baseCurrency = "USD", + dismiss = {}, + onAdd = { to, rate -> }, + ) + } +} \ No newline at end of file diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 0000000..7b2c4c3 --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,2 @@ +json_key_file("google-play-console-user.json") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one +package_name("com.ivy.wallet") # e.g. com.krausefx.app diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..140ec8e --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,68 @@ +# This file contains the fastlane.tools configuration +# You can find the documentation at https://docs.fastlane.tools +# +# For a list of all available actions, check out +# +# https://docs.fastlane.tools/actions +# +# For a list of all available plugins, check out +# +# https://docs.fastlane.tools/plugins/available-plugins +# + +# Uncomment the line if you want fastlane to automatically update itself +# update_fastlane + +default_platform(:android) + +platform :android do + desc "Run UI tests" + lane :ui_tests do + gradle( + task: "connectedDebugAndroidTest" + ) + end + + desc "Verify that the code is release-able." + lane :lint_release do + #verify code quality for "Release" buildType + gradle( + task: "lint", + build_type: "Release" + ) + end + + desc "Builds release app bundle (AAB) for the Google Play Store." + lane :production_build do + gradle( + task: "clean" + ) + + gradle( + task: "bundle", + build_type: "Release" + ) + end + + desc "Releases a generated AAB to Google PlayStore's Internal Testing" + lane :internal_release do + supply( + track: "internal", + aab: "app/build/outputs/bundle/release/app-release.aab", + #release_status: "draft", + rollout: "1", + skip_upload_apk: true, + skip_upload_metadata: true, + skip_upload_changelogs: false, + skip_upload_images: true, + skip_upload_screenshots: true + ) + end + + desc "Cleans the project" + lane :clean do + gradle( + task: "clean" + ) + end +end diff --git a/fastlane/metadata/android/en-GB/changelogs/default.txt b/fastlane/metadata/android/en-GB/changelogs/default.txt new file mode 100644 index 0000000..75a94b0 --- /dev/null +++ b/fastlane/metadata/android/en-GB/changelogs/default.txt @@ -0,0 +1 @@ +Ivy Wallet internal testing release, please refer to (https://github.com/Ivy-Apps/ivy-wallet/releases) for changelog. \ No newline at end of file diff --git a/formula/README.md b/formula/README.md new file mode 100644 index 0000000..af4d983 --- /dev/null +++ b/formula/README.md @@ -0,0 +1,19 @@ +# Fomules + +Wish to be Excel/Google Sheets like formulas support for querying your Ivy transactions. + +## 🚧 Module under construction... + +If it hardly works, it's filled with bad code and anti-patterns anyway... + +### To see how a proper should look like refer to: + +- **[:core](../core)**: responsible for Ivy Wallet's domain +- **[:home](../home/)**: Ivy wallet's home screen. + +Apologies for the inconvenience! I'm a solo dev + the help of our awesome Ivy contributors. If you +want to support us: + +1. Star our repo. + [![GitHub Repo stars](https://img.shields.io/github/stars/Ivy-Apps/ivy-wallet?style=social)](https://github.com/Ivy-Apps/ivy-wallet/stargazers) +2. Have a look at our [Contributors Guide](../CONTRIBUTING.md). \ No newline at end of file diff --git a/formula/domain/.gitignore b/formula/domain/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/formula/domain/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/formula/domain/README.md b/formula/domain/README.md new file mode 100644 index 0000000..57201c5 --- /dev/null +++ b/formula/domain/README.md @@ -0,0 +1,36 @@ +# Formulas + +Formulas are similar to Excel, spreadsheet formulas that you already know. They're used to calculate +stuff within Ivy Wallet. + +Here we'll explain how do they work. + +## Formula + +### Input + +Formulas accept a non-empty list of `Flow` which can be three types: + +- **Value**: a constant double value _(e.g. 20,000, 80, 10, 15)_. +- **Data source**: a of calculated transactions stats _(e.g. sum of all incomes this month)_. +- **Other formula**: the output of another formula. + +### Transformation + +### Output + +Formulas always output a flow of double `Flow`. + +## Data Source + +### Filter + +### Focus + +## Project + +### Project Info + +### Set of formulas + +### Visualization \ No newline at end of file diff --git a/formula/domain/build.gradle.kts b/formula/domain/build.gradle.kts new file mode 100644 index 0000000..647e6b8 --- /dev/null +++ b/formula/domain/build.gradle.kts @@ -0,0 +1,16 @@ +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":core:domain")) + Testing() +} \ No newline at end of file diff --git a/formula/domain/src/main/AndroidManifest.xml b/formula/domain/src/main/AndroidManifest.xml new file mode 100644 index 0000000..dd0e58c --- /dev/null +++ b/formula/domain/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/formula/domain/src/main/java/com/ivy/formula/domain/action/DataSourceFlow.kt b/formula/domain/src/main/java/com/ivy/formula/domain/action/DataSourceFlow.kt new file mode 100644 index 0000000..8ff6393 --- /dev/null +++ b/formula/domain/src/main/java/com/ivy/formula/domain/action/DataSourceFlow.kt @@ -0,0 +1,42 @@ +package com.ivy.formula.domain.action + +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.domain.action.calculate.CalculateFlow +import com.ivy.core.domain.action.transaction.TrnsFlow +import com.ivy.formula.domain.data.source.CalculationThing +import com.ivy.formula.domain.data.source.CalculationType +import com.ivy.formula.domain.data.source.DataSource +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class DataSourceFlow @Inject constructor( + private val trnsFlow: TrnsFlow, + private val calculateFlow: CalculateFlow +) : FlowAction() { + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + override fun createFlow(input: DataSource): Flow = + trnsFlow(input.filter).flatMapLatest { trns -> + calculateFlow( + CalculateFlow.Input( + trns = trns, + includeTransfers = false, + includeHidden = false, + outputCurrency = input.calculation.outputCurrency + ) + ) + }.map { stats -> + val byValue = input.calculation.type == CalculationType.ByValue + when (input.calculation.thing) { + CalculationThing.Income -> + if (byValue) stats.income.amount else stats.incomesCount + CalculationThing.Expense -> + if (byValue) stats.expense.amount else stats.expensesCount + CalculationThing.Balance -> if (byValue) + stats.balance.amount else (stats.incomesCount - stats.expensesCount) + }.toDouble() + } +} \ No newline at end of file diff --git a/formula/domain/src/main/java/com/ivy/formula/domain/action/FormulaFlow.kt b/formula/domain/src/main/java/com/ivy/formula/domain/action/FormulaFlow.kt new file mode 100644 index 0000000..5368464 --- /dev/null +++ b/formula/domain/src/main/java/com/ivy/formula/domain/action/FormulaFlow.kt @@ -0,0 +1,45 @@ +package com.ivy.formula.domain.action + +import arrow.core.NonEmptyList +import com.ivy.common.toNonEmptyList +import com.ivy.core.domain.action.FlowAction +import com.ivy.formula.domain.data.formula.Formula +import com.ivy.formula.domain.data.formula.FormulaInput +import com.ivy.formula.domain.pure.parse.compileFunction +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +class FormulaFlow @Inject constructor( + private val dataSourceFlow: DataSourceFlow +) : FlowAction() { + override fun createFlow(input: Formula): Flow = executeFormula(input) + + private fun executeFormula(formula: Formula): Flow = + executeFunction( + input = provideInput(formula.input), + function = formula.function + ) + + private fun provideInput( + input: NonEmptyList + ): Flow> { + val inputFlows = input.map { + when (it) { + is FormulaInput.OtherFormula -> executeFormula(it.formula) + is FormulaInput.Source -> dataSourceFlow(it.source) + is FormulaInput.Value -> flowOf(it.value) + } + } + + return combine(inputFlows) { + it.toList().toNonEmptyList() + } + } + + private fun executeFunction( + input: Flow>, + function: String, + ): Flow = compileFunction(function).invoke(input) +} \ No newline at end of file diff --git a/formula/domain/src/main/java/com/ivy/formula/domain/data/formula/Formula.kt b/formula/domain/src/main/java/com/ivy/formula/domain/data/formula/Formula.kt new file mode 100644 index 0000000..0713741 --- /dev/null +++ b/formula/domain/src/main/java/com/ivy/formula/domain/data/formula/Formula.kt @@ -0,0 +1,10 @@ +package com.ivy.formula.domain.data.formula + +import arrow.core.NonEmptyList + +data class Formula( + val id: String, + val displayName: String, + val input: NonEmptyList, + val function: String, +) \ No newline at end of file diff --git a/formula/domain/src/main/java/com/ivy/formula/domain/data/formula/FormulaInput.kt b/formula/domain/src/main/java/com/ivy/formula/domain/data/formula/FormulaInput.kt new file mode 100644 index 0000000..9856667 --- /dev/null +++ b/formula/domain/src/main/java/com/ivy/formula/domain/data/formula/FormulaInput.kt @@ -0,0 +1,9 @@ +package com.ivy.formula.domain.data.formula + +import com.ivy.formula.domain.data.source.DataSource + +sealed interface FormulaInput { + data class Value(val value: Double) : FormulaInput + data class OtherFormula(val formula: Formula) : FormulaInput + data class Source(val source: DataSource) : FormulaInput +} \ No newline at end of file diff --git a/formula/domain/src/main/java/com/ivy/formula/domain/data/project/Project.kt b/formula/domain/src/main/java/com/ivy/formula/domain/data/project/Project.kt new file mode 100644 index 0000000..a5bc400 --- /dev/null +++ b/formula/domain/src/main/java/com/ivy/formula/domain/data/project/Project.kt @@ -0,0 +1,13 @@ +package com.ivy.formula.domain.data.project + +import arrow.core.NonEmptyList +import com.ivy.data.CurrencyCode +import com.ivy.data.time.TimePeriod +import com.ivy.formula.domain.data.formula.Formula + +data class Project( + val info: ProjectInfo, + val formulas: NonEmptyList, + val period: TimePeriod, + val currency: CurrencyCode, +) \ No newline at end of file diff --git a/formula/domain/src/main/java/com/ivy/formula/domain/data/project/ProjectInfo.kt b/formula/domain/src/main/java/com/ivy/formula/domain/data/project/ProjectInfo.kt new file mode 100644 index 0000000..88faa93 --- /dev/null +++ b/formula/domain/src/main/java/com/ivy/formula/domain/data/project/ProjectInfo.kt @@ -0,0 +1,11 @@ +package com.ivy.formula.domain.data.project + +import androidx.compose.ui.graphics.Color +import com.ivy.data.ItemIconId + +data class ProjectInfo( + val name: String, + val description: String, + val color: Color, + val iconId: ItemIconId +) \ No newline at end of file diff --git a/formula/domain/src/main/java/com/ivy/formula/domain/data/project/ProjectView.kt b/formula/domain/src/main/java/com/ivy/formula/domain/data/project/ProjectView.kt new file mode 100644 index 0000000..190ae5d --- /dev/null +++ b/formula/domain/src/main/java/com/ivy/formula/domain/data/project/ProjectView.kt @@ -0,0 +1,8 @@ +package com.ivy.formula.domain.data.project + +import arrow.core.NonEmptyList +import com.ivy.formula.domain.data.formula.Formula + +data class ProjectView( + val formulas: NonEmptyList +) \ No newline at end of file diff --git a/formula/domain/src/main/java/com/ivy/formula/domain/data/source/Calculation.kt b/formula/domain/src/main/java/com/ivy/formula/domain/data/source/Calculation.kt new file mode 100644 index 0000000..cddfbd7 --- /dev/null +++ b/formula/domain/src/main/java/com/ivy/formula/domain/data/source/Calculation.kt @@ -0,0 +1,12 @@ +package com.ivy.formula.domain.data.source + +import com.ivy.data.CurrencyCode + +/** + * @param outputCurrency use **null** for base currency + */ +data class Calculation( + val thing: CalculationThing, + val type: CalculationType, + val outputCurrency: CurrencyCode?, +) \ No newline at end of file diff --git a/formula/domain/src/main/java/com/ivy/formula/domain/data/source/CalculationThing.kt b/formula/domain/src/main/java/com/ivy/formula/domain/data/source/CalculationThing.kt new file mode 100644 index 0000000..a15ed50 --- /dev/null +++ b/formula/domain/src/main/java/com/ivy/formula/domain/data/source/CalculationThing.kt @@ -0,0 +1,5 @@ +package com.ivy.formula.domain.data.source + +enum class CalculationThing { + Income, Expense, Balance +} \ No newline at end of file diff --git a/formula/domain/src/main/java/com/ivy/formula/domain/data/source/CalculationType.kt b/formula/domain/src/main/java/com/ivy/formula/domain/data/source/CalculationType.kt new file mode 100644 index 0000000..3cc81d4 --- /dev/null +++ b/formula/domain/src/main/java/com/ivy/formula/domain/data/source/CalculationType.kt @@ -0,0 +1,5 @@ +package com.ivy.formula.domain.data.source + +enum class CalculationType { + ByValue, ByCount +} \ No newline at end of file diff --git a/formula/domain/src/main/java/com/ivy/formula/domain/data/source/DataSource.kt b/formula/domain/src/main/java/com/ivy/formula/domain/data/source/DataSource.kt new file mode 100644 index 0000000..c80b318 --- /dev/null +++ b/formula/domain/src/main/java/com/ivy/formula/domain/data/source/DataSource.kt @@ -0,0 +1,8 @@ +package com.ivy.formula.domain.data.source + +import com.ivy.core.domain.action.transaction.TrnQuery + +data class DataSource( + val filter: TrnQuery, + val calculation: Calculation, +) \ No newline at end of file diff --git a/formula/domain/src/main/java/com/ivy/formula/domain/pure/parse/CompileFunction.kt b/formula/domain/src/main/java/com/ivy/formula/domain/pure/parse/CompileFunction.kt new file mode 100644 index 0000000..3c20b00 --- /dev/null +++ b/formula/domain/src/main/java/com/ivy/formula/domain/pure/parse/CompileFunction.kt @@ -0,0 +1,26 @@ +package com.ivy.formula.domain.pure.parse + +import arrow.core.Either +import arrow.core.NonEmptyList +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf + +@OptIn(FlowPreview::class) +fun compileFunction( + function: String +): (Flow>) -> Flow = { argsFlow -> + argsFlow.flatMapLatest { args -> + val parser = FunctionParser(args) + when (val result = parser.parse(normalizeFunction(function))) { + is Either.Left -> error(result.value) + is Either.Right -> flowOf(result.value) + } + } +} + +private fun normalizeFunction(function: String): String = + function.replace("=", "") + .replace(" ", "") + .trim() \ No newline at end of file diff --git a/formula/domain/src/main/java/com/ivy/formula/domain/pure/parse/FunctionParser.kt b/formula/domain/src/main/java/com/ivy/formula/domain/pure/parse/FunctionParser.kt new file mode 100644 index 0000000..4df6c9d --- /dev/null +++ b/formula/domain/src/main/java/com/ivy/formula/domain/pure/parse/FunctionParser.kt @@ -0,0 +1,62 @@ +package com.ivy.formula.domain.pure.parse + +import arrow.core.Either +import arrow.core.NonEmptyList +import arrow.core.computations.either +import arrow.core.left + +data class FunctionParser(val args: NonEmptyList) { + + @Suppress("IMPLICIT_NOTHING_TYPE_ARGUMENT_IN_RETURN_POSITION") + suspend fun parse(function: String): Either = either { + val result = expr().invoke(function) + if (result.isEmpty()) "Parse error.".left().bind() + if (result.size > 1) "Ambiguous result.".left().bind() + val leftover = result.first().leftover + if (leftover.isNotEmpty()) + "Not completely parsed, leftover: \"$leftover\".".left().bind() + + Either.Right(result.first().value).bind() + } + + private fun expr(): Parser = term().flatMap { x -> + char('+').flatMap { + expr().flatMap { y -> + pure(x + y) + } + } + } or term() + + private fun term(): Parser = factor().flatMap { x -> + char('%').flatMap { + pure(x / 100) + } + } or factor().flatMap { x -> + char('*').flatMap { + term().flatMap { y -> + pure(x * y) + } + } + } or factor().flatMap { x -> + char('/').flatMap { + term().flatMap { y -> + pure(x / y) + } + } + } or factor() + + private fun factor(): Parser = char('(').flatMap { + expr().flatMap { x -> + char(')').flatMap { + pure(x) + } + } + } or argument() + + private fun argument(): Parser = char('$').flatMap { + sat { it.isDigit() }.flatMap { digit -> + val index = digit.digitToInt() - 1 + pure(args[index]) + } + } +} diff --git a/formula/domain/src/main/java/com/ivy/formula/domain/pure/parse/Parser.kt b/formula/domain/src/main/java/com/ivy/formula/domain/pure/parse/Parser.kt new file mode 100644 index 0000000..48bef25 --- /dev/null +++ b/formula/domain/src/main/java/com/ivy/formula/domain/pure/parse/Parser.kt @@ -0,0 +1,105 @@ +package com.ivy.formula.domain.pure.parse + +/** + * Motivated by FUNCTIONAL PEARL + * Monadic parsing in Haskell + * by Graham Hutton & Erik Meijer + */ + +data class ParseResult( + val value: T, + val leftover: String, +) + +typealias Parser = (String) -> List> + +fun pure(value: T): Parser = { text -> + listOf(ParseResult(value, text)) +} + +fun empty(): Parser = { emptyList() } + +fun Parser.flatMap( + f: (T) -> Parser +): Parser = { string -> + val result = this(string) // apply parser 1 + + // apply parser 2 on the result of parser 1 + result.flatMap { + f(it.value).invoke(it.leftover) + } +} + +infix fun Parser.or(parser2: Parser): Parser = { text -> + this(text).takeIf { it.isNotEmpty() } ?: parser2(text) +} + +fun combine(parser1: Parser, parser2: Parser): Parser = { text -> + parser1(text) + parser2(text) +} + +fun combineFirst(parser1: Parser, parser2: Parser): Parser = { text -> + val res = combine(parser1, parser2).invoke(text) + res.take(1) +} + +// region Functions +fun item(): Parser = { string -> + if (string.isNotEmpty()) { + // return the first character as value and the rest as leftover + listOf( + ParseResult( + value = string.first(), + leftover = string.drop(1) + ) + ) + } else emptyList() +} + +fun peek(): Parser = { string -> + if (string.isNotEmpty()) { + listOf(ParseResult(value = string.first(), leftover = string)) + } else emptyList() +} + +/** + * Satisfies a given predicate. + */ +fun sat(predicate: (Char) -> Boolean): Parser = { string -> + item().flatMap { char -> + if (predicate(char)) pure(char) else empty() + }.invoke(string) +} + +fun char(c: Char): Parser = sat { it == c } + +fun charIn(str: String): Parser = sat { str.contains(it) } + +fun string(str: String): Parser = { string -> + if (str.isEmpty()) pure("").invoke(string) else { + // recurse + char(str.first()).flatMap { c -> + string(str.drop(1)).flatMap { cs -> + pure(c + cs) + } + }.invoke(string) + } +} + +fun zeroOrMany(parser: Parser): Parser> { + fun oneOrMany(parser: Parser): Parser> = + parser.flatMap { one -> + zeroOrMany(parser).flatMap { many -> + pure(listOf(one) + many) + } + } + + return combineFirst(oneOrMany(parser), pure(emptyList())) +} + +fun oneOrMany(parser: Parser): Parser> = parser.flatMap { one -> + zeroOrMany(parser).flatMap { many -> + pure(listOf(one) + many) + } +} +// endregion \ No newline at end of file diff --git a/formula/persistence/.gitignore b/formula/persistence/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/formula/persistence/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/formula/persistence/build.gradle.kts b/formula/persistence/build.gradle.kts new file mode 100644 index 0000000..db94919 --- /dev/null +++ b/formula/persistence/build.gradle.kts @@ -0,0 +1,30 @@ +import com.ivy.buildsrc.DataStore +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.RoomDB + +apply() + +plugins { + `android-library` + `kotlin-android` + `kotlin-kapt` // for Room DB +} + +android { + defaultConfig { + kapt { + arguments { + arg("room.schemaLocation", "$projectDir/../room-db-schemas") + } + } + } +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":core:domain")) + implementation(project(":core:persistence")) + RoomDB(api = false) + DataStore(api = false) +} \ No newline at end of file diff --git a/formula/persistence/src/main/AndroidManifest.xml b/formula/persistence/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2440ce2 --- /dev/null +++ b/formula/persistence/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/formula/room-db-schemas/com.ivy.formula.persistence.IvyWalletFormulaDb/1.json b/formula/room-db-schemas/com.ivy.formula.persistence.IvyWalletFormulaDb/1.json new file mode 100644 index 0000000..93e4cbb --- /dev/null +++ b/formula/room-db-schemas/com.ivy.formula.persistence.IvyWalletFormulaDb/1.json @@ -0,0 +1,164 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "40cfe45a22ba84df23feb9435b6a5a3b", + "entities": [ + { + "tableName": "formulas", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_formulas_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_formulas_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "projects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_projects_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_projects_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "project_formula_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `projectId` TEXT NOT NULL, `formulaId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "projectId", + "columnName": "projectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "formulaId", + "columnName": "formulaId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_project_formula_links_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_project_formula_links_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_project_formula_links_projectId", + "unique": false, + "columnNames": [ + "projectId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_project_formula_links_projectId` ON `${TABLE_NAME}` (`projectId`)" + }, + { + "name": "index_project_formula_links_formulaId", + "unique": false, + "columnNames": [ + "formulaId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_project_formula_links_formulaId` ON `${TABLE_NAME}` (`formulaId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "data_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_data_sources_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_sources_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '40cfe45a22ba84df23feb9435b6a5a3b')" + ] + } +} \ No newline at end of file diff --git a/formula/ui/.gitignore b/formula/ui/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/formula/ui/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/formula/ui/build.gradle.kts b/formula/ui/build.gradle.kts new file mode 100644 index 0000000..e990e01 --- /dev/null +++ b/formula/ui/build.gradle.kts @@ -0,0 +1,16 @@ +import com.ivy.buildsrc.Hilt + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":core:ui")) + implementation(project(":design-system")) + implementation(project(":formula:domain")) +} \ No newline at end of file diff --git a/formula/ui/src/main/AndroidManifest.xml b/formula/ui/src/main/AndroidManifest.xml new file mode 100644 index 0000000..fe5cc0b --- /dev/null +++ b/formula/ui/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/free_ram.sh b/free_ram.sh new file mode 100755 index 0000000..7ce93e5 --- /dev/null +++ b/free_ram.sh @@ -0,0 +1 @@ +pkill -f '.*GradleDaemon.*' diff --git a/google-services.json b/google-services.json new file mode 100644 index 0000000..bc0dcf3 --- /dev/null +++ b/google-services.json @@ -0,0 +1,85 @@ +{ + "project_info": { + "project_number": "364763737033", + "firebase_url": "https://ivy-widget.firebaseio.com", + "project_id": "ivy-widget", + "storage_bucket": "ivy-widget.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:364763737033:android:634d1eb97e9254b3bc5618", + "android_client_info": { + "package_name": "com.ivy.wallet" + } + }, + "oauth_client": [ + { + "client_id": "364763737033-k9vbsfmeou12rscq134lclc7sbr0608e.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.ivy.wallet", + "certificate_hash": "f9b8505c2740756c704f8782b6fc3ddcaaea3d80" + } + }, + { + "client_id": "364763737033-t1d2qe7s0s8597k7anu3sb2nq79ot5tp.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyB4FVF29Qu17MhHmf-55l7l7W09hiMQuJM" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "364763737033-8f0ta4r3t5cjmavkhmdnjcufpcg6ror6.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:364763737033:android:7e379f986cb86320bc5618", + "android_client_info": { + "package_name": "com.ivy.wallet.debug" + } + }, + "oauth_client": [ + { + "client_id": "364763737033-ktnhou2u7q3num6aj02jj0v11dnp5imi.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.ivy.wallet.debug", + "certificate_hash": "962730459b106b8aa8fb225430deb6a97c559699" + } + }, + { + "client_id": "364763737033-t1d2qe7s0s8597k7anu3sb2nq79ot5tp.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyB4FVF29Qu17MhHmf-55l7l7W09hiMQuJM" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "364763737033-8f0ta4r3t5cjmavkhmdnjcufpcg6ror6.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..b7b51d8 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,33 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +#org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=false +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Speed-up Gradle Builds! (OPTIMIZATION) +org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=800m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC +kotlin.daemon.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=700m -XX:+UseParallelGC +org.gradle.configureondemand=true +org.gradle.parallel=true +org.gradle.workers.max=8 +org.gradle.daemon=true +org.gradle.caching=true +android.experimental.cacheCompileLibResources=true +# TODO: Experimental, remove if problems occur +org.gradle.unsafe.configuration-cache=true + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ffd9494 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue Sep 27 14:25:06 EEST 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# 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 +# +# https://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. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/main/README.md b/main/README.md new file mode 100644 index 0000000..360b5ed --- /dev/null +++ b/main/README.md @@ -0,0 +1,6 @@ +# Main + +Ivy Wallet's "Main" screen hosting: +- "Home" tab +- "Accounts" tab +- "More" menu \ No newline at end of file diff --git a/main/accounts/.gitignore b/main/accounts/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/main/accounts/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/main/accounts/README.md b/main/accounts/README.md new file mode 100644 index 0000000..94dc6f0 --- /dev/null +++ b/main/accounts/README.md @@ -0,0 +1,10 @@ +# Accounts + +Implements the "Accounts" tab that you can see on the Main screen. + +**Features:** +- CRUD accounts. +- CRUD account folders. +- Reorder accounts and folders. +- See accounts grouped by folders. +- See "net worth", total balance and excluded balance. \ No newline at end of file diff --git a/main/accounts/build.gradle.kts b/main/accounts/build.gradle.kts new file mode 100644 index 0000000..b7e17de --- /dev/null +++ b/main/accounts/build.gradle.kts @@ -0,0 +1,20 @@ +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":design-system")) + implementation(project(":core:data-model")) + implementation(project(":core:ui")) + implementation(project(":core:domain")) + implementation(project(":navigation")) + implementation(project(":main:bottom-bar")) + Testing() +} \ No newline at end of file diff --git a/main/accounts/src/main/AndroidManifest.xml b/main/accounts/src/main/AndroidManifest.xml new file mode 100644 index 0000000..63b7162 --- /dev/null +++ b/main/accounts/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/main/accounts/src/main/java/com/ivy/accounts/AccountsEvent.kt b/main/accounts/src/main/java/com/ivy/accounts/AccountsEvent.kt new file mode 100644 index 0000000..968f67d --- /dev/null +++ b/main/accounts/src/main/java/com/ivy/accounts/AccountsEvent.kt @@ -0,0 +1,9 @@ +package com.ivy.accounts + +sealed interface AccountsEvent { + object NavigateToHome : AccountsEvent + + object BottomBarActionClick : AccountsEvent + object ShowBottomBar : AccountsEvent + object HideBottomBar : AccountsEvent +} \ No newline at end of file diff --git a/main/accounts/src/main/java/com/ivy/accounts/AccountsScreen.kt b/main/accounts/src/main/java/com/ivy/accounts/AccountsScreen.kt new file mode 100644 index 0000000..ead637a --- /dev/null +++ b/main/accounts/src/main/java/com/ivy/accounts/AccountsScreen.kt @@ -0,0 +1,259 @@ +package com.ivy.accounts + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.accounts.components.accountsList +import com.ivy.accounts.data.AccountListItemUi +import com.ivy.accounts.modal.CreateModal +import com.ivy.accounts.modal.NetWorthInfoModal +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.dummyValueUi +import com.ivy.core.ui.account.create.CreateAccountModal +import com.ivy.core.ui.account.edit.EditAccountModal +import com.ivy.core.ui.account.folder.create.CreateAccFolderModal +import com.ivy.core.ui.account.folder.edit.EditAccFolderModal +import com.ivy.core.ui.account.reorder.ReorderAccountsModal +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.core.ui.data.account.dummyFolderUi +import com.ivy.core.ui.value.AmountCurrency +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.Blue +import com.ivy.design.l0_system.color.Red +import com.ivy.design.l1_buildingBlocks.B1 +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.ReorderButton +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.hiltViewModelPreviewSafe +import com.ivy.main.bottombar.MainBottomBar +import com.ivy.main.bottombar.Tab +import com.ivy.wallet.utils.horizontalSwipeListener +import kotlinx.coroutines.launch + +@Composable +fun BoxScope.AccountsScreen() { + val viewModel: AccountsScreenViewModel? = hiltViewModelPreviewSafe() + val state = viewModel?.uiState?.collectAsState()?.value + ?: previewState() + + UI(state = state, onEvent = { viewModel?.onEvent(it) }) +} + +@Composable +private fun BoxScope.UI( + state: AccountsState, + onEvent: (AccountsEvent) -> Unit, +) { + val editAccountModal = rememberIvyModal() + var editAccountId by remember { mutableStateOf(null) } + val editFolderModal = rememberIvyModal() + var editFolderId by remember { mutableStateOf(null) } + + val reorderModal = rememberIvyModal() + val createAccountModal = rememberIvyModal() + val netWorthInfoModal = rememberIvyModal() + + BackHandler(enabled = true) { + onEvent(AccountsEvent.NavigateToHome) + } + + val lazyListState = rememberLazyListState() + val firstVisibleItemIndex by remember { + derivedStateOf { lazyListState.firstVisibleItemIndex } + } + LaunchedEffect(firstVisibleItemIndex) { + if (firstVisibleItemIndex > 0) { + onEvent(AccountsEvent.HideBottomBar) + } else { + onEvent(AccountsEvent.ShowBottomBar) + } + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + .horizontalSwipeListener( + sensitivity = 200, + onSwipeLeft = { + onEvent(AccountsEvent.NavigateToHome) + }, + onSwipeRight = { + onEvent(AccountsEvent.NavigateToHome) + } + ), + state = lazyListState, + ) { + item(key = "header") { + SpacerVer(height = 16.dp) + Header( + totalBalance = state.totalBalance, + onNetWorthClick = { + netWorthInfoModal.show() + }, + onReorder = { + reorderModal.show() + } + ) + SpacerVer(height = 4.dp) + } + accountsList( + items = state.items, + noAccounts = state.noAccounts, + onAccountClick = { + editAccountId = it.id + editAccountModal.show() + }, + onFolderClick = { + editFolderId = it.id + editFolderModal.show() + }, + onCreateAccount = { + createAccountModal.show() + } + ) + item { + SpacerVer(height = 300.dp) // last item spacer + } + } + + + val coroutineScope = rememberCoroutineScope() + MainBottomBar( + visible = state.bottomBarVisible, + selectedTab = Tab.Accounts, + onActionClick = { + onEvent(AccountsEvent.BottomBarActionClick) + }, + onAccountsClick = { + // scroll to top + coroutineScope.launch { + lazyListState.animateScrollToItem(0) + } + }, + onHomeClick = { + onEvent(AccountsEvent.NavigateToHome) + } + ) + + val createFolderModal = rememberIvyModal() + CreateModal( + modal = state.createModal, + onCreateAccount = { createAccountModal.show() }, + onCreateFolder = { createFolderModal.show() } + ) + CreateAccountModal(modal = createAccountModal) + CreateAccFolderModal(modal = createFolderModal) + + editAccountId?.let { + EditAccountModal(modal = editAccountModal, accountId = it) + } + editFolderId?.let { + EditAccFolderModal(modal = editFolderModal, folderId = it) + } + + NetWorthInfoModal( + modal = netWorthInfoModal, + totalBalance = state.totalBalance, + availableBalance = state.availableBalance, + excludedBalance = state.excludedBalance, + ) + ReorderAccountsModal(modal = reorderModal) +} + +@Composable +private fun Header( + totalBalance: ValueUi, + onNetWorthClick: () -> Unit, + onReorder: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .clickable(onClick = onNetWorthClick) + ) { + B1(text = "Net-worth") + Row( + verticalAlignment = Alignment.CenterVertically + ) { + AmountCurrency(totalBalance, color = UI.colors.primary) + } + } + SpacerHor(width = 4.dp) + ReorderButton(onClick = onReorder) + } +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + AccountsScreen() + } +} + +private fun previewState() = AccountsState( + totalBalance = dummyValueUi("203k"), + availableBalance = dummyValueUi("136,3k"), + excludedBalance = dummyValueUi("64,3k"), + noAccounts = false, + items = listOf( + AccountListItemUi.AccountWithBalance( + account = dummyAccountUi("Cash"), + balance = dummyValueUi("240.75"), + balanceBaseCurrency = null, + ), + AccountListItemUi.FolderWithAccounts( + folder = dummyFolderUi("Business"), + balance = dummyValueUi("5,320.50"), + accItems = listOf( + AccountListItemUi.AccountWithBalance( + account = dummyAccountUi("Account 1"), + balance = dummyValueUi("1,000.00", "BGN"), + balanceBaseCurrency = dummyValueUi("500") + ), + AccountListItemUi.AccountWithBalance( + account = dummyAccountUi("Account 2", color = Blue, excluded = true), + balance = dummyValueUi("0.00"), + balanceBaseCurrency = null + ), + AccountListItemUi.AccountWithBalance( + account = dummyAccountUi("Account 3", color = Red), + balance = dummyValueUi("4,320.50"), + balanceBaseCurrency = null + ), + ), + accountsCount = 3, + ), + AccountListItemUi.AccountWithBalance( + account = dummyAccountUi("Revolut", color = Blue), + balance = dummyValueUi("1,032.54"), + balanceBaseCurrency = null + ), + AccountListItemUi.Archived( + accHolders = listOf(), + accountsCount = 0, + ) + ), + createModal = IvyModal(), + bottomBarVisible = true, +) +// endregion diff --git a/main/accounts/src/main/java/com/ivy/accounts/AccountsScreenViewModel.kt b/main/accounts/src/main/java/com/ivy/accounts/AccountsScreenViewModel.kt new file mode 100644 index 0000000..82fc5bb --- /dev/null +++ b/main/accounts/src/main/java/com/ivy/accounts/AccountsScreenViewModel.kt @@ -0,0 +1,208 @@ +package com.ivy.accounts + +import com.ivy.accounts.data.AccountListItemUi +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.action.account.folder.AccountFoldersFlow +import com.ivy.core.domain.action.data.AccountListItem +import com.ivy.core.domain.action.exchange.ExchangeFlow +import com.ivy.core.domain.action.exchange.SumValuesInCurrencyFlow +import com.ivy.core.domain.algorithm.balance.AccBalanceFlow +import com.ivy.core.domain.algorithm.balance.TotalBalanceFlow +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.format +import com.ivy.core.domain.pure.util.combineList +import com.ivy.core.ui.action.mapping.account.MapAccountUiAct +import com.ivy.core.ui.action.mapping.account.MapFolderUiAct +import com.ivy.data.Value +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.navigation.Navigator +import com.ivy.navigation.destinations.Destination +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +@HiltViewModel +class AccountsScreenViewModel @Inject constructor( + private val accountFoldersFlow: AccountFoldersFlow, + private val mapAccountUiAct: MapAccountUiAct, + private val mapFolderUiAct: MapFolderUiAct, + private val sumValuesInCurrencyFlow: SumValuesInCurrencyFlow, + private val exchangeFlow: ExchangeFlow, + private val totalBalanceFlow: TotalBalanceFlow, + private val navigator: Navigator, + private val accBalanceFlow: AccBalanceFlow +) : SimpleFlowViewModel() { + override val initialUi: AccountsState = AccountsState( + totalBalance = ValueUi("", ""), + availableBalance = ValueUi("", ""), + excludedBalance = ValueUi("", ""), + items = emptyList(), + noAccounts = false, + createModal = IvyModal(), + bottomBarVisible = true, + ) + + private val bottomBarVisible = MutableStateFlow(initialUi.bottomBarVisible) + + override val uiFlow: Flow = combine( + accListItemsUiFlow(), totalBalanceFlow(), availableBalanceFlow(), bottomBarVisible + ) { items, totalBalance, availableBalance, bottomBarVisible -> + val excludedBalance = Value( + amount = totalBalance.amount - availableBalance.amount, + currency = totalBalance.currency + ) + AccountsState( + totalBalance = format(totalBalance, shortenFiat = true), + availableBalance = format(availableBalance, shortenFiat = true), + excludedBalance = format(excludedBalance, shortenFiat = true), + noAccounts = items.none { + // no items (accounts) that match the predicate + when (it) { + is AccountListItemUi.AccountWithBalance -> true + is AccountListItemUi.Archived -> it.accountsCount > 0 + is AccountListItemUi.FolderWithAccounts -> it.accountsCount > 0 + } + }, + items = items, + createModal = initialUi.createModal, + bottomBarVisible = bottomBarVisible, + ) + } + + private fun totalBalanceFlow(): Flow = totalBalanceFlow( + TotalBalanceFlow.Input(withExcluded = true) + ) + + private fun availableBalanceFlow(): Flow = totalBalanceFlow( + TotalBalanceFlow.Input(withExcluded = false) + ) + + // TODO: Re-work this, it's just ugly! + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + private fun accListItemsUiFlow(): Flow> = + accountFoldersFlow(Unit).map { items -> + items + .filter { + when (it) { + is AccountListItem.AccountHolder -> true + // allow empty folders + is AccountListItem.FolderWithAccounts -> true + is AccountListItem.Archived -> it.accounts.isNotEmpty() + } + } + .map { item -> + when (item) { + is AccountListItem.AccountHolder -> + item to listOf(accBalanceFlow(item.account)) + is AccountListItem.FolderWithAccounts -> item to item.accounts + .map { accBalanceFlow(it) } + is AccountListItem.Archived -> item to item.accounts + .map { accBalanceFlow(it) } + } + }.map { (item, balanceFlows) -> + // Handle empty folders with no accounts inside + if (balanceFlows.isEmpty()) + flowOf(item to listOf()) else combine(balanceFlows) { balances -> + item to balances.toList() + } + } + }.flatMapLatest(transform = ::combineList) + .map(::toAccListItemsUi) + .flatMapLatest(transform = ::combineList) + + private suspend fun toAccListItemsUi( + itemBalances: List>> + ): List> = itemBalances.map { (item, balances) -> + when (item) { + is AccountListItem.AccountHolder -> { + val accBalance = balances.first() + exchangeFlow(ExchangeFlow.Input(accBalance)).map { balanceBaseCurrency -> + AccountListItemUi.AccountWithBalance( + account = mapAccountUiAct(item.account), + balance = format(accBalance, shortenFiat = false), + balanceBaseCurrency = balanceBaseCurrency( + baseCurrency = balanceBaseCurrency, + currency = accBalance, + ) + ) + } + } + is AccountListItem.FolderWithAccounts -> combine( + sumValuesInCurrencyFlow(SumValuesInCurrencyFlow.Input(balances)), + combineList(balances.map { exchangeFlow(ExchangeFlow.Input(it)) }) + ) { folderBalance, balancesBaseCurrency -> + AccountListItemUi.FolderWithAccounts( + folder = mapFolderUiAct(item.accountFolder), + accItems = item.accounts.mapIndexed { index, acc -> + AccountListItemUi.AccountWithBalance( + account = mapAccountUiAct(acc), + balance = format(balances[index], shortenFiat = false), + balanceBaseCurrency = balanceBaseCurrency( + baseCurrency = balancesBaseCurrency[index], + currency = balances[index] + ) + ) + }, + accountsCount = item.accounts.size, + balance = format(folderBalance, shortenFiat = true) + ) + } + is AccountListItem.Archived -> combineList( + balances.map { exchangeFlow(ExchangeFlow.Input(it)) } + ).map { balancesBaseCurrency -> + AccountListItemUi.Archived( + accHolders = item.accounts.mapIndexed { index, acc -> + AccountListItemUi.AccountWithBalance( + account = mapAccountUiAct(acc), + balance = format(balances[index], shortenFiat = false), + balanceBaseCurrency = balanceBaseCurrency( + baseCurrency = balancesBaseCurrency[index], + currency = balances[index] + ) + ) + }, + accountsCount = item.accounts.size, + ) + } + } + } + + private fun balanceBaseCurrency( + baseCurrency: Value, + currency: Value + ): ValueUi? = baseCurrency.takeIf { + it.currency != currency.currency && it.amount != 0.0 + }?.let { format(it, shortenFiat = true) } + + + // region Event Handling + override suspend fun handleEvent(event: AccountsEvent) = when (event) { + AccountsEvent.BottomBarActionClick -> handleBottomBarAction() + AccountsEvent.NavigateToHome -> handleNavigateToHome() + AccountsEvent.HideBottomBar -> handleHideBottomBar() + AccountsEvent.ShowBottomBar -> handleShowBottomBar() + } + + private fun handleBottomBarAction() { + uiState.value.createModal.show() + } + + private fun handleNavigateToHome() { + navigator.navigate(Destination.home.destination(Unit)) { + popUpTo(Destination.home.route) { + inclusive = true + } + } + } + + private fun handleShowBottomBar() { + bottomBarVisible.value = true + } + + private fun handleHideBottomBar() { + bottomBarVisible.value = false + } + // endregion +} \ No newline at end of file diff --git a/main/accounts/src/main/java/com/ivy/accounts/AccountsState.kt b/main/accounts/src/main/java/com/ivy/accounts/AccountsState.kt new file mode 100644 index 0000000..06d0fa9 --- /dev/null +++ b/main/accounts/src/main/java/com/ivy/accounts/AccountsState.kt @@ -0,0 +1,17 @@ +package com.ivy.accounts + +import androidx.compose.runtime.Immutable +import com.ivy.accounts.data.AccountListItemUi +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.design.l2_components.modal.IvyModal + +@Immutable +data class AccountsState( + val totalBalance: ValueUi, + val availableBalance: ValueUi, + val excludedBalance: ValueUi, + val noAccounts: Boolean, + val items: List, + val createModal: IvyModal, + val bottomBarVisible: Boolean, +) \ No newline at end of file diff --git a/main/accounts/src/main/java/com/ivy/accounts/components/AccountCard.kt b/main/accounts/src/main/java/com/ivy/accounts/components/AccountCard.kt new file mode 100644 index 0000000..14b23cb --- /dev/null +++ b/main/accounts/src/main/java/com/ivy/accounts/components/AccountCard.kt @@ -0,0 +1,145 @@ +package com.ivy.accounts.components + +import androidx.compose.foundation.background +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.accounts.R +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.dummyValueUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.core.ui.data.icon.IconSize +import com.ivy.core.ui.icon.ItemIcon +import com.ivy.core.ui.value.AmountCurrency +import com.ivy.core.ui.value.AmountCurrencySmall +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.rememberContrast +import com.ivy.design.l0_system.color.rememberDynamicContrast +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l1_buildingBlocks.Caption +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.util.ComponentPreview + +@Composable +fun AccountCard( + account: AccountUi, + balance: ValueUi, + balanceBaseCurrency: ValueUi?, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + val dynamicContrast = rememberDynamicContrast(account.color) + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .clip(UI.shapes.rounded) + .background(account.color, UI.shapes.rounded) + .border(1.dp, dynamicContrast, UI.shapes.rounded) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp) + .padding(top = 4.dp, bottom = 12.dp), + ) { + val contrastColor = rememberContrast(account.color) + Header(account = account, color = contrastColor, dynamicContrast = dynamicContrast) + SpacerVer(height = 4.dp) + Balance(balance = balance, color = contrastColor) + BalanceBaseCurrency(balanceBaseCurrency = balanceBaseCurrency, color = dynamicContrast) + } +} + +@Composable +private fun Header( + account: AccountUi, + color: Color, + dynamicContrast: Color, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + ItemIcon( + itemIcon = account.icon, + size = IconSize.M, + tint = color, + ) + SpacerHor(width = 8.dp) + B2( + modifier = Modifier.weight(1f), + text = account.name, + color = color, + fontWeight = FontWeight.ExtraBold + ) + if (account.excluded) { + SpacerHor(width = 4.dp) + Caption(text = stringResource(R.string.excluded), color = dynamicContrast) + } + } +} + +@Composable +private fun Balance( + balance: ValueUi, + color: Color, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(start = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AmountCurrency(value = balance, color = color) + } +} + +@Composable +private fun BalanceBaseCurrency( + balanceBaseCurrency: ValueUi?, + color: Color, + modifier: Modifier = Modifier, +) { + if (balanceBaseCurrency != null) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(top = 2.dp) + .padding(start = 14.dp), // so it looks aligned with the balance + verticalAlignment = Alignment.CenterVertically + ) { + AmountCurrencySmall(value = balanceBaseCurrency, color = color) + } + } +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + ComponentPreview { + AccountCard( + account = dummyAccountUi(excluded = true), + balance = dummyValueUi("1,324.50"), + balanceBaseCurrency = dummyValueUi("2,972.95", "BGN") + ) { + + } + } +} +// endregion \ No newline at end of file diff --git a/main/accounts/src/main/java/com/ivy/accounts/components/AccountFolderCard.kt b/main/accounts/src/main/java/com/ivy/accounts/components/AccountFolderCard.kt new file mode 100644 index 0000000..6ffe6e6 --- /dev/null +++ b/main/accounts/src/main/java/com/ivy/accounts/components/AccountFolderCard.kt @@ -0,0 +1,246 @@ +package com.ivy.accounts.components + +import androidx.compose.animation.* +import androidx.compose.foundation.background +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.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.accounts.R +import com.ivy.accounts.data.AccountListItemUi.AccountWithBalance +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.dummyValueUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.account.FolderUi +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.core.ui.data.account.dummyFolderUi +import com.ivy.core.ui.data.icon.IconSize +import com.ivy.core.ui.data.icon.ItemIcon +import com.ivy.core.ui.icon.ItemIcon +import com.ivy.core.ui.value.AmountCurrency +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.Blue +import com.ivy.design.l0_system.color.Red +import com.ivy.design.l0_system.color.rememberContrast +import com.ivy.design.l0_system.color.rememberDynamicContrast +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.isInPreview + + +@Composable +fun AccountFolderCard( + folder: FolderUi, + balance: ValueUi, + accounts: List, + accountsCount: Int, + modifier: Modifier = Modifier, + onAccountClick: (AccountUi) -> Unit, + onFolderClick: () -> Unit, +) { + val dynamicContrast = rememberDynamicContrast(folder.color) + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .clip(UI.shapes.rounded) + .border(1.dp, dynamicContrast, UI.shapes.rounded) + .clickable(onClick = onFolderClick), + ) { + val contrastColor = rememberContrast(folder.color) + Column( + modifier = Modifier + .fillMaxWidth() + .background(folder.color, UI.shapes.roundedTop) + .padding(horizontal = 16.dp) + .padding(top = 4.dp, bottom = 12.dp) + ) { + IconNameRow(folderName = folder.name, folderIcon = folder.icon, color = contrastColor) + SpacerVer(height = 2.dp) + Balance(balance = balance, color = contrastColor) + } + var expanded by if (isInPreview()) remember { + mutableStateOf(previewExpanded) + } else remember { mutableStateOf(false) } + ExpandCollapse( + expanded = expanded, + color = UI.colorsInverted.pure, + accountsCount = accountsCount, + onSetExpanded = { expanded = it } + ) + Accounts(expanded = expanded, items = accounts, onClick = onAccountClick) + } +} + +@Composable +private fun IconNameRow( + folderName: String, + folderIcon: ItemIcon, + color: Color, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + ItemIcon( + itemIcon = folderIcon, + size = IconSize.M, + tint = color, + ) + SpacerHor(width = 8.dp) + B2( + modifier = Modifier.weight(1f), + text = folderName, + color = color, + fontWeight = FontWeight.ExtraBold + ) + } +} + +@Composable +private fun Balance( + balance: ValueUi, + color: Color, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(start = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AmountCurrency(value = balance, color = color) + } +} + +@Composable +private fun ExpandCollapse( + expanded: Boolean, + color: Color, + accountsCount: Int, + onSetExpanded: (Boolean) -> Unit +) { + if (accountsCount > 0) { + IvyButton( + size = ButtonSize.Big, + shape = UI.shapes.roundedBottom, + visibility = Visibility.Low, + feeling = Feeling.Custom(color), + text = if (expanded) + "Tap to collapse ($accountsCount)" else "Tap to expand ($accountsCount)", + icon = if (expanded) + R.drawable.ic_round_expand_less_24 else R.drawable.round_expand_more_24 + ) { + onSetExpanded(!expanded) + } + } else { + B2( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + text = "Empty folder", + fontWeight = FontWeight.ExtraBold, + color = UI.colors.neutral, + textAlign = TextAlign.Center + ) + } + +} + +@Composable +private fun Accounts( + expanded: Boolean, + items: List, + onClick: (AccountUi) -> Unit +) { + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column(Modifier.fillMaxWidth()) { + items.forEach { + key("${it.account.id}${it.balance.amount}") { + AccountCard( + account = it.account, + balance = it.balance, + balanceBaseCurrency = it.balanceBaseCurrency, + onClick = { onClick(it.account) } + ) + SpacerVer(height = 8.dp) + } + } + SpacerVer(height = 4.dp) + } + } +} + + +// region Preview +private var previewExpanded = false + +@Preview +@Composable +private fun Preview_Collapsed() { + ComponentPreview { + AccountFolderCard( + folder = dummyFolderUi("Business"), + balance = dummyValueUi("5,320.50"), + accounts = emptyList(), + accountsCount = 0, + onAccountClick = {}, + onFolderClick = {}, + ) + } +} + +@Preview +@Composable +private fun Preview_Expanded() { + ComponentPreview { + previewExpanded = true + AccountFolderCard( + folder = dummyFolderUi("Business"), + balance = dummyValueUi("5,320.50"), + accounts = listOf( + AccountWithBalance( + account = dummyAccountUi("Account 1"), + balance = dummyValueUi("1,000.00", "ADA"), + balanceBaseCurrency = dummyValueUi("358.76") + ), + AccountWithBalance( + account = dummyAccountUi("Account 2", color = Blue, excluded = true), + balance = dummyValueUi("0.00"), + balanceBaseCurrency = null + ), + AccountWithBalance( + account = dummyAccountUi("Account 3", color = Red), + balance = dummyValueUi("4,320.50"), + balanceBaseCurrency = null + ), + ), + accountsCount = 3, + onAccountClick = {}, + onFolderClick = {}, + ) + } +} +// endregion \ No newline at end of file diff --git a/main/accounts/src/main/java/com/ivy/accounts/components/AccountsList.kt b/main/accounts/src/main/java/com/ivy/accounts/components/AccountsList.kt new file mode 100644 index 0000000..f01ad2c --- /dev/null +++ b/main/accounts/src/main/java/com/ivy/accounts/components/AccountsList.kt @@ -0,0 +1,133 @@ +package com.ivy.accounts.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +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.ivy.accounts.R +import com.ivy.accounts.data.AccountListItemUi +import com.ivy.accounts.data.AccountListItemUi.* +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.account.FolderUi +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.B1 +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview + +fun LazyListScope.accountsList( + items: List, + noAccounts: Boolean, + onAccountClick: (AccountUi) -> Unit, + onFolderClick: (FolderUi) -> Unit, + onCreateAccount: () -> Unit, +) { + items( + items = items, + key = { + when (it) { + is AccountWithBalance -> "acc_${it.account.id}" + is FolderWithAccounts -> "folder_${it.folder.id}" + is Archived -> "archived_accounts" + } + } + ) { item -> + when (item) { + is AccountWithBalance -> { + SpacerVer(height = 8.dp) + AccountCard( + account = item.account, + balance = item.balance, + balanceBaseCurrency = item.balanceBaseCurrency, + onClick = { onAccountClick(item.account) } + ) + } + is FolderWithAccounts -> { + SpacerVer(height = 8.dp) + AccountFolderCard( + folder = item.folder, + balance = item.balance, + accounts = item.accItems, + accountsCount = item.accountsCount, + onAccountClick = onAccountClick, + onFolderClick = { + onFolderClick(item.folder) + }, + ) + } + is Archived -> { + SpacerVer(height = 16.dp) + ArchivedAccounts(archived = item, onAccountClick = onAccountClick) + } + } + } + + if (noAccounts) { + item { + EmptyState(onCreateAccount = onCreateAccount) + } + } +} + +@Composable +private fun EmptyState( + onCreateAccount: () -> Unit +) { + SpacerVer(height = 96.dp) + B1( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = "No accounts", + textAlign = TextAlign.Center, + color = UI.colors.primary + ) + SpacerVer(height = 12.dp) + B2( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = "To use Ivy Wallet you need to create an account first.", + textAlign = TextAlign.Center + ) + SpacerVer(height = 12.dp) + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Focused, + feeling = Feeling.Positive, + text = "Create account", + icon = R.drawable.ic_vue_money_wallet, + onClick = onCreateAccount, + ) + SpacerVer(height = 24.dp) +} + + +// region Preview +@Preview +@Composable +private fun Preview_EmptyState() { + ComponentPreview { + LazyColumn { + accountsList( + items = emptyList(), + noAccounts = true, + onFolderClick = {}, + onCreateAccount = {}, + onAccountClick = {}, + ) + } + } +} +// endregion \ No newline at end of file diff --git a/main/accounts/src/main/java/com/ivy/accounts/components/ArchivedAccounts.kt b/main/accounts/src/main/java/com/ivy/accounts/components/ArchivedAccounts.kt new file mode 100644 index 0000000..221e92d --- /dev/null +++ b/main/accounts/src/main/java/com/ivy/accounts/components/ArchivedAccounts.kt @@ -0,0 +1,125 @@ +package com.ivy.accounts.components + +import androidx.compose.animation.* +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.* +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.accounts.R +import com.ivy.accounts.data.AccountListItemUi +import com.ivy.core.domain.pure.format.dummyValueUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.design.l0_system.color.Blue +import com.ivy.design.l0_system.color.Red +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.isInPreview + +@Composable +internal fun ArchivedAccounts( + archived: AccountListItemUi.Archived, + onAccountClick: (AccountUi) -> Unit, +) { + var expanded by if (isInPreview()) remember { + mutableStateOf(previewExpanded) + } else remember { mutableStateOf(false) } + ArchivedDivider( + expanded = expanded, + accountsCount = archived.accountsCount, + onSetExpanded = { expanded = it } + ) + AccountsList( + accounts = archived.accHolders, + expanded = expanded, + onAccountClick = onAccountClick + ) +} + +@Composable +private fun ArchivedDivider( + expanded: Boolean, + accountsCount: Int, + onSetExpanded: (Boolean) -> Unit +) { + IvyButton( + size = ButtonSize.Big, + visibility = Visibility.Low, + feeling = Feeling.Neutral, + text = "Archived ($accountsCount)", + icon = if (expanded) + R.drawable.round_expand_more_24 else R.drawable.ic_round_expand_less_24 + ) { + onSetExpanded(!expanded) + } +} + +@Composable +private fun AccountsList( + accounts: List, + expanded: Boolean, + onAccountClick: (AccountUi) -> Unit, +) { + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column { + accounts.forEach { item -> + key("archived_${item.account.id}") { + SpacerVer(height = 12.dp) + AccountCard( + account = item.account, + balance = item.balance, + balanceBaseCurrency = item.balanceBaseCurrency + ) { + onAccountClick(item.account) + } + } + } + } + } +} + + +// region Preview +private var previewExpanded = false + +@Preview +@Composable +private fun Preview() { + ComponentPreview { + previewExpanded = true + Column { + ArchivedAccounts( + archived = AccountListItemUi.Archived( + accHolders = listOf( + AccountListItemUi.AccountWithBalance( + account = dummyAccountUi("Account 1"), + balance = dummyValueUi("1,000.00", "BGN"), + balanceBaseCurrency = dummyValueUi("500") + ), + AccountListItemUi.AccountWithBalance( + account = dummyAccountUi("Account 2", color = Blue, excluded = true), + balance = dummyValueUi("0.00"), + balanceBaseCurrency = null + ), + AccountListItemUi.AccountWithBalance( + account = dummyAccountUi("Account 3", color = Red), + balance = dummyValueUi("4,320.50"), + balanceBaseCurrency = null + ), + ), + accountsCount = 3, + ), + onAccountClick = {} + ) + } + } +} +// endregion \ No newline at end of file diff --git a/main/accounts/src/main/java/com/ivy/accounts/data/AccountListItemUi.kt b/main/accounts/src/main/java/com/ivy/accounts/data/AccountListItemUi.kt new file mode 100644 index 0000000..fe562ca --- /dev/null +++ b/main/accounts/src/main/java/com/ivy/accounts/data/AccountListItemUi.kt @@ -0,0 +1,30 @@ +package com.ivy.accounts.data + +import androidx.compose.runtime.Immutable +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.account.FolderUi + +@Immutable +sealed interface AccountListItemUi { + @Immutable + data class AccountWithBalance( + val account: AccountUi, + val balance: ValueUi, + val balanceBaseCurrency: ValueUi?, + ) : AccountListItemUi + + @Immutable + data class FolderWithAccounts( + val folder: FolderUi, + val accItems: List, + val accountsCount: Int, + val balance: ValueUi, + ) : AccountListItemUi + + @Immutable + data class Archived( + val accHolders: List, + val accountsCount: Int, + ) : AccountListItemUi +} \ No newline at end of file diff --git a/main/accounts/src/main/java/com/ivy/accounts/modal/CreateModal.kt b/main/accounts/src/main/java/com/ivy/accounts/modal/CreateModal.kt new file mode 100644 index 0000000..42b27cc --- /dev/null +++ b/main/accounts/src/main/java/com/ivy/accounts/modal/CreateModal.kt @@ -0,0 +1,85 @@ +package com.ivy.accounts.modal + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.accounts.R +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview + +@Composable +internal fun BoxScope.CreateModal( + modal: IvyModal, + onCreateAccount: () -> Unit, + onCreateFolder: () -> Unit, +) { + Modal(modal = modal, actions = {}) { + Title(text = stringResource(R.string.create)) + SpacerVer(height = 24.dp) + FolderButton { + modal.hide() + onCreateFolder() + } + SpacerVer(height = 12.dp) + AccountButton { + modal.hide() + onCreateAccount() + } + SpacerVer(height = 24.dp) + } +} + +@Composable +private fun FolderButton(onClick: () -> Unit) { + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = "New folder", + icon = R.drawable.ic_vue_files_folder, + onClick = onClick, + ) +} + +@Composable +private fun AccountButton(onClick: () -> Unit) { + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.High, + feeling = Feeling.Positive, + text = "New account", + icon = R.drawable.ic_custom_account_s, + onClick = onClick, + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = rememberIvyModal() + modal.show() + CreateModal( + modal = modal, + onCreateAccount = {}, + onCreateFolder = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/main/accounts/src/main/java/com/ivy/accounts/modal/NetWorthInfoModal.kt b/main/accounts/src/main/java/com/ivy/accounts/modal/NetWorthInfoModal.kt new file mode 100644 index 0000000..25b5c68 --- /dev/null +++ b/main/accounts/src/main/java/com/ivy/accounts/modal/NetWorthInfoModal.kt @@ -0,0 +1,87 @@ +package com.ivy.accounts.modal + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.dummyValueUi +import com.ivy.core.ui.value.AmountCurrency +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.B1Second +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Body +import com.ivy.design.l2_components.modal.components.Positive +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.previewModal +import com.ivy.design.util.IvyPreview + +@Composable +internal fun BoxScope.NetWorthInfoModal( + modal: IvyModal, + totalBalance: ValueUi, + availableBalance: ValueUi, + excludedBalance: ValueUi, +) { + Modal( + modal = modal, + actions = { + Positive(text = "Got it") { + modal.hide() + } + } + ) { + Title(text = "Net-worth") + SpacerVer(height = 4.dp) + Body( + text = "Your net-worth is the combined value of all your assets" + + " minus your liabilities." + ) + SpacerVer(height = 24.dp) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + B2(text = "Available balance") + Row { + AmountCurrency(availableBalance) + } + B1Second(text = "+") + B2(text = "Excluded balance") + Row { + AmountCurrency(excludedBalance, color = UI.colors.red) + } + B1Second(text = "=") + B2(text = "Net-worth") + Row { + AmountCurrency(totalBalance, color = UI.colors.primary) + } + } + SpacerVer(height = 24.dp) + } +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = previewModal() + NetWorthInfoModal( + modal = modal, + totalBalance = dummyValueUi("203k"), + availableBalance = dummyValueUi("136,3k"), + excludedBalance = dummyValueUi("64,3k"), + ) + } +} +// endregion \ No newline at end of file diff --git a/main/bottom-bar/.gitignore b/main/bottom-bar/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/main/bottom-bar/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/main/bottom-bar/build.gradle.kts b/main/bottom-bar/build.gradle.kts new file mode 100644 index 0000000..a742cf6 --- /dev/null +++ b/main/bottom-bar/build.gradle.kts @@ -0,0 +1,18 @@ +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":design-system")) + implementation(project(":core:ui")) + implementation(project(":navigation")) + implementation(project(":resources")) + Testing() +} \ No newline at end of file diff --git a/main/bottom-bar/src/main/AndroidManifest.xml b/main/bottom-bar/src/main/AndroidManifest.xml new file mode 100644 index 0000000..86852c6 --- /dev/null +++ b/main/bottom-bar/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/main/bottom-bar/src/main/java/com/ivy/main/bottombar/MainBottomBar.kt b/main/bottom-bar/src/main/java/com/ivy/main/bottombar/MainBottomBar.kt new file mode 100644 index 0000000..4218d8f --- /dev/null +++ b/main/bottom-bar/src/main/java/com/ivy/main/bottombar/MainBottomBar.kt @@ -0,0 +1,221 @@ +package com.ivy.main.bottombar + +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.animation.slideInBottom +import com.ivy.design.animation.slideOutBottom +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l1_buildingBlocks.IconRes +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview +import com.ivy.design.util.consumeClicks +import com.ivy.resources.R + +enum class Tab { + Home, Accounts +} + +@Composable +fun BoxScope.MainBottomBar( + visible: Boolean, + selectedTab: Tab, + modifier: Modifier = Modifier, + onActionClick: (Tab) -> Unit, + onHomeClick: () -> Unit, + onAccountsClick: () -> Unit, +) { + AnimatedVisibility( + modifier = modifier + .align(Alignment.BottomCenter) + .systemBarsPadding() + .padding(bottom = 8.dp), + visible = visible, + enter = slideInBottom() + fadeIn(), + exit = slideOutBottom() + fadeOut(), + ) { + BottomBarRow( + selectedTab = selectedTab, + onActionClick = onActionClick, + onHomeClick = onHomeClick, + onAccountsClick = onAccountsClick + ) + } +} + +@Composable +private fun BottomBarRow( + selectedTab: Tab, + modifier: Modifier = Modifier, + onActionClick: (Tab) -> Unit, + onHomeClick: () -> Unit, + onAccountsClick: () -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .background( + color = UI.colors.medium.copy(alpha = 0.9f), + shape = UI.shapes.rounded + ) + .border(1.dp, UI.colors.primary, UI.shapes.rounded) + .consumeClicks() + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Tab( + text = stringResource(R.string.home), + selected = selectedTab == Tab.Home, + icon = R.drawable.ic_home, + modifier = Modifier.weight(1f), + onClick = onHomeClick + ) + SpacerHor(width = 4.dp) + ActionButton { + onActionClick(selectedTab) + } + SpacerHor(width = 4.dp) + Tab( + text = stringResource(R.string.accounts), + selected = selectedTab == Tab.Accounts, + icon = R.drawable.ic_accounts, + modifier = Modifier.weight(1f), + onClick = onAccountsClick, + ) + } +} + + +@Composable +private fun Tab( + text: String, + selected: Boolean, + @DrawableRes + icon: Int, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Row( + modifier = modifier + .clip(UI.shapes.rounded) + .clickable(onClick = onClick) + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + IconRes( + icon = icon, + tint = if (selected) UI.colors.primary else UI.colorsInverted.pure, + ) + if (selected) { + SpacerVer(height = 8.dp) + B2( + text = text, + color = UI.colors.primary + ) + } + } +} + +@Composable +private fun ActionButton( + onClick: () -> Unit +) { + IvyButton( + modifier = Modifier.size(52.dp), +// modifier = Modifier.pointerInput(Unit) { +// detectDragGestures( +// onDragCancel = { +// dragOffset = Offset.Zero +// }, +// onDragEnd = { +// dragOffset = Offset.Zero +// }, +// onDrag = { _, dragAmount -> +// dragOffset += dragAmount +// +// val horizontalThreshold = 40 +// val verticalThreshold = 60 +// +// when { +// abs(dragOffset.x) < horizontalThreshold && +// dragOffset.y < -verticalThreshold -> { +// // swipe up +// dragOffset = Offset.Zero // prevent double open of the screen +// onSwipeUp() +// } +// dragOffset.x < -horizontalThreshold && +// dragOffset.y < -verticalThreshold -> { +// //swipe up left +// dragOffset = Offset.Zero // prevent double open of the screen +// onSwipeDiagonalLeft() +// } +// dragOffset.x > horizontalThreshold && +// dragOffset.y < -verticalThreshold -> { +// // swipe up right +// dragOffset = Offset.Zero // prevent double open of the screen +// onSwipeDiagonalRight() +// } +// } +// } +// ) +// }, + size = ButtonSize.Small, + visibility = Visibility.Focused, + feeling = Feeling.Positive, + text = null, + icon = R.drawable.ic_round_add_24, + onClick = onClick + ) +} + + +// region Previews +@Preview +@Composable +private fun Preview_Home() { + ComponentPreview { + MainBottomBar( + visible = true, + modifier = Modifier.padding(horizontal = 16.dp), + selectedTab = Tab.Home, + onActionClick = {}, + onHomeClick = { }, + onAccountsClick = {} + ) + } +} + +@Preview +@Composable +private fun Preview_Account() { + ComponentPreview { + MainBottomBar( + visible = true, + modifier = Modifier.padding(horizontal = 16.dp), + selectedTab = Tab.Accounts, + onActionClick = {}, + onHomeClick = { }, + onAccountsClick = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/main/bottom-bar/src/main/java/com/ivy/main/bottombar/MainBottomBarAction.kt b/main/bottom-bar/src/main/java/com/ivy/main/bottombar/MainBottomBarAction.kt new file mode 100644 index 0000000..f75cd64 --- /dev/null +++ b/main/bottom-bar/src/main/java/com/ivy/main/bottombar/MainBottomBarAction.kt @@ -0,0 +1,5 @@ +package com.ivy.main.bottombar + +enum class MainBottomBarAction { + Click, SwipeUp, SwipeDiagonalLeft, SwipeDiagonalRight +} \ No newline at end of file diff --git a/main/bottom-bar/src/main/java/com/ivy/main/bottombar/MainBottomBarVisibility.kt b/main/bottom-bar/src/main/java/com/ivy/main/bottombar/MainBottomBarVisibility.kt new file mode 100644 index 0000000..b207e93 --- /dev/null +++ b/main/bottom-bar/src/main/java/com/ivy/main/bottombar/MainBottomBarVisibility.kt @@ -0,0 +1,10 @@ +package com.ivy.main.bottombar + +import kotlinx.coroutines.flow.MutableStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MainBottomBarVisibility @Inject constructor() { + val visible = MutableStateFlow(false) +} \ No newline at end of file diff --git a/main/customer-journey/.gitignore b/main/customer-journey/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/main/customer-journey/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/main/customer-journey/README.md b/main/customer-journey/README.md new file mode 100644 index 0000000..e1d0f1a --- /dev/null +++ b/main/customer-journey/README.md @@ -0,0 +1,15 @@ +# 🚧 Module under construction... + +If it hardly works, it's filled with bad code and anti-patterns anyway... + +### To see how a proper should look like refer to: + +- **[:core](../core)**: responsible for Ivy Wallet's domain +- **[:home](../home/)**: Ivy wallet's home screen. + +Apologies for the inconvenience! I'm a solo dev + the help of our awesome Ivy contributors. If you +want to support us: + +1. Star our repo. + [![GitHub Repo stars](https://img.shields.io/github/stars/Ivy-Apps/ivy-wallet?style=social)](https://github.com/Ivy-Apps/ivy-wallet/stargazers) +2. Have a look at our [Contributors Guide](../CONTRIBUTING.md). \ No newline at end of file diff --git a/main/customer-journey/build.gradle.kts b/main/customer-journey/build.gradle.kts new file mode 100644 index 0000000..6d901ce --- /dev/null +++ b/main/customer-journey/build.gradle.kts @@ -0,0 +1,19 @@ +import com.ivy.buildsrc.Hilt + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":design-system")) + implementation(project(":core:ui")) + implementation(project(":core:data-model")) + implementation(project(":navigation")) + + implementation(project(":widgets")) +} \ No newline at end of file diff --git a/main/customer-journey/src/main/AndroidManifest.xml b/main/customer-journey/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d364308 --- /dev/null +++ b/main/customer-journey/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/main/home/.gitignore b/main/home/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/main/home/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/main/home/README.md b/main/home/README.md new file mode 100644 index 0000000..b23b435 --- /dev/null +++ b/main/home/README.md @@ -0,0 +1,9 @@ +# Home Tab + +Ivy Wallet's home tab, the screen that you'll more commonly see when you open the app. + +**Features** +- Balance +- Income & Expense +- Transaction's history +- Add transaction navigation \ No newline at end of file diff --git a/main/home/build.gradle.kts b/main/home/build.gradle.kts new file mode 100644 index 0000000..d88c3ec --- /dev/null +++ b/main/home/build.gradle.kts @@ -0,0 +1,21 @@ +import com.ivy.buildsrc.Hilt + +apply() + +plugins { + `android-library` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":design-system")) + implementation(project(":core:ui")) + implementation(project(":core:data-model")) + implementation(project(":navigation")) + implementation(project(":core:domain")) + implementation(project(":core:persistence")) + + implementation(project(":main:bottom-bar")) + implementation(project(":main:customer-journey")) +} \ No newline at end of file diff --git a/main/home/src/main/AndroidManifest.xml b/main/home/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7ee75d9 --- /dev/null +++ b/main/home/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/main/home/src/main/java/com/ivy/home/HomeEvent.kt b/main/home/src/main/java/com/ivy/home/HomeEvent.kt new file mode 100644 index 0000000..15953f0 --- /dev/null +++ b/main/home/src/main/java/com/ivy/home/HomeEvent.kt @@ -0,0 +1,20 @@ +package com.ivy.home + +sealed interface HomeEvent { + object BalanceClick : HomeEvent + object IncomeClick : HomeEvent + object ExpenseClick : HomeEvent + object HiddenBalanceClick : HomeEvent + object MoreClick : HomeEvent + + sealed interface BottomBar : HomeEvent { + object Show : BottomBar + object Hide : BottomBar + object AddClick : BottomBar + object AccountsClick : BottomBar + } + + object AddTransfer : HomeEvent + object AddIncome : HomeEvent + object AddExpense : HomeEvent +} \ No newline at end of file diff --git a/main/home/src/main/java/com/ivy/home/HomeScreen.kt b/main/home/src/main/java/com/ivy/home/HomeScreen.kt new file mode 100644 index 0000000..b938c26 --- /dev/null +++ b/main/home/src/main/java/com/ivy/home/HomeScreen.kt @@ -0,0 +1,298 @@ +package com.ivy.home + +import androidx.compose.animation.* +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.ui.time.PeriodButton +import com.ivy.core.ui.time.PeriodModal +import com.ivy.core.ui.transaction.TransactionsLazyColumn +import com.ivy.core.ui.transaction.rememberTransactionsListState +import com.ivy.core.ui.transaction.sampleTrnListItems +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.DividerHor +import com.ivy.design.l1_buildingBlocks.DividerSize +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l1_buildingBlocks.SpacerWeight +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.consumeClicks +import com.ivy.home.components.Balance +import com.ivy.home.components.BalanceMini +import com.ivy.home.components.IncomeExpense +import com.ivy.home.components.MoreMenuButton +import com.ivy.home.modal.AddTransactionModal +import com.ivy.main.bottombar.MainBottomBar +import com.ivy.main.bottombar.Tab +import com.ivy.wallet.utils.horizontalSwipeListener +import kotlinx.coroutines.launch + +@Composable +fun BoxScope.HomeScreen() { + val viewModel: HomeViewModel = hiltViewModel() + val state by viewModel.uiState.collectAsState() + + UI( + state = state, + onEvent = viewModel::onEvent, + ) +} + +@Composable +private fun BoxScope.UI( + state: HomeStateUi, + onEvent: (HomeEvent) -> Unit, +) { + val periodModal = rememberIvyModal() + + val trnsListState = rememberTransactionsListState( + scrollStateKey = "home_tab" + ) + TransactionsLazyColumn( + modifier = Modifier + .systemBarsPadding() + .horizontalSwipeListener( + sensitivity = 200, + onSwipeLeft = { + onEvent(HomeEvent.BottomBar.AccountsClick) + }, + onSwipeRight = { + onEvent(HomeEvent.BottomBar.AccountsClick) + } + ), + items = state.trnListItems, + state = trnsListState, + contentAboveTrns = { listState -> + header( + periodModal = periodModal, + balance = state.balance, + income = state.income, + expense = state.expense, + listState = listState, + onBalanceClick = { onEvent(HomeEvent.BalanceClick) }, + onIncomeClick = { onEvent(HomeEvent.IncomeClick) }, + onExpenseClick = { onEvent(HomeEvent.ExpenseClick) }, + onMoreClick = { onEvent(HomeEvent.MoreClick) } + ) + }, + contentBelowTrns = { + item { + // TODO: Change that to 300.dp when we have transactions + SpacerVer(height = 600.dp) + } + }, + onFirstVisibleItemChange = { firstVisibleItemIndex -> + if (firstVisibleItemIndex > 0) { + onEvent(HomeEvent.BottomBar.Hide) + } else { + onEvent(HomeEvent.BottomBar.Show) + } + } + ) + + val coroutineScope = rememberCoroutineScope() + MainBottomBar( + visible = state.bottomBarVisible, + selectedTab = Tab.Home, + onActionClick = { onEvent(HomeEvent.BottomBar.AddClick) }, + onHomeClick = { + // Scroll to top + coroutineScope.launch { + trnsListState.listState.animateScrollToItem(0) + } + }, + onAccountsClick = { onEvent(HomeEvent.BottomBar.AccountsClick) } + ) + + Modals( + periodModal = periodModal, + addTransactionModal = state.addTransactionModal, + onEvent = onEvent, + ) +} + +// region Header +fun LazyListScope.header( + periodModal: IvyModal, + balance: ValueUi, + income: ValueUi, + expense: ValueUi, + listState: LazyListState, + onBalanceClick: () -> Unit, + onMoreClick: () -> Unit, + onIncomeClick: () -> Unit, + onExpenseClick: () -> Unit, +) { + toolbar( + periodModal = periodModal, + balance = balance, + listState = listState, + onBalanceClick = onBalanceClick, + onMoreClick = onMoreClick, + ) + item(key = "home_header_balance") { + SpacerVer(height = 4.dp) + Balance(balance = balance, onClick = onBalanceClick) + } + item(key = "home_header_income_expense") { + SpacerVer(height = 4.dp) + IncomeExpense( + income = income, + expense = expense, + onIncomeClick = onIncomeClick, + onExpenseClick = onExpenseClick, + ) + } + item(key = "header_divider_line") { + SpacerVer(height = 24.dp) + DividerHor() + } +} + +@OptIn(ExperimentalFoundationApi::class) +private fun LazyListScope.toolbar( + periodModal: IvyModal, + balance: ValueUi, + listState: LazyListState, + onBalanceClick: () -> Unit, + onMoreClick: () -> Unit = {}, +) { + stickyHeader( + key = "home_tab_toolbar", + contentType = null + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(UI.colors.pure) + .padding(top = 12.dp, bottom = 4.dp) + .padding(horizontal = 16.dp) + .consumeClicks(), + verticalAlignment = Alignment.CenterVertically + ) { + PeriodButton(periodModal = periodModal) + SpacerWeight(weight = 1f) + MoreMenuButton(onClick = onMoreClick) + } + + val headerCollapsed by remember { + derivedStateOf { listState.firstVisibleItemIndex > 1 } + } + AnimatedVisibility( + modifier = Modifier.background(UI.colors.pure), + visible = headerCollapsed, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + val coroutineScope = rememberCoroutineScope() + CollapsedToolbarExtension( + balance = balance, + onBalanceClick = onBalanceClick, + onScrollToTop = { + coroutineScope.launch { + listState.animateScrollToItem(0) + } + } + ) + } + } +} + +@Composable +private fun CollapsedToolbarExtension( + balance: ValueUi, + onBalanceClick: () -> Unit, + onScrollToTop: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .consumeClicks() + ) { + Row( + modifier = Modifier.padding( + start = 24.dp, end = 8.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + BalanceMini( + balance = balance, + onClick = onBalanceClick + ) + SpacerWeight(weight = 1f) + IvyButton( + size = ButtonSize.Small, + visibility = Visibility.Low, + feeling = Feeling.Positive, + text = "Go to top", + typo = UI.typo.c, + onClick = onScrollToTop, + ) + } + SpacerVer(height = 4.dp) + DividerHor(size = DividerSize.FillMax(padding = 0.dp)) + } +} +// endregion + +// region Modals +@Composable +private fun BoxScope.Modals( + periodModal: IvyModal, + addTransactionModal: IvyModal, + onEvent: (HomeEvent) -> Unit, +) { + PeriodModal( + modal = periodModal, + ) + + AddTransactionModal( + modal = addTransactionModal, + onAddTransfer = { + onEvent(HomeEvent.AddTransfer) + }, + onAddIncome = { + onEvent(HomeEvent.AddIncome) + }, + onAddExpense = { + onEvent(HomeEvent.AddExpense) + } + ) +} +// endregion + + +// region Previews +@Preview +@Composable +private fun Preview() { + IvyPreview { + UI( + state = HomeStateUi( + balance = ValueUi("10,000.00", "USD"), + income = ValueUi("1,500.35", "USD"), + expense = ValueUi("3,000.50", "USD"), + hideBalance = false, + trnListItems = sampleTrnListItems(), + bottomBarVisible = true, + addTransactionModal = rememberIvyModal() + ), + onEvent = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/main/home/src/main/java/com/ivy/home/HomeStateUi.kt b/main/home/src/main/java/com/ivy/home/HomeStateUi.kt new file mode 100644 index 0000000..670a6ad --- /dev/null +++ b/main/home/src/main/java/com/ivy/home/HomeStateUi.kt @@ -0,0 +1,19 @@ +package com.ivy.home + +import androidx.compose.runtime.Immutable +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.ui.algorithm.trnhistory.data.TrnListItemUi +import com.ivy.design.l2_components.modal.IvyModal + +@Immutable +data class HomeStateUi( + val balance: ValueUi, + val income: ValueUi, + val expense: ValueUi, + val trnListItems: List, + + val hideBalance: Boolean, + val bottomBarVisible: Boolean, + + val addTransactionModal: IvyModal, +) \ No newline at end of file diff --git a/main/home/src/main/java/com/ivy/home/HomeViewModel.kt b/main/home/src/main/java/com/ivy/home/HomeViewModel.kt new file mode 100644 index 0000000..f2093d4 --- /dev/null +++ b/main/home/src/main/java/com/ivy/home/HomeViewModel.kt @@ -0,0 +1,159 @@ +package com.ivy.home + +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.algorithm.balance.TotalBalanceFlow +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.format +import com.ivy.core.ui.algorithm.trnhistory.PeriodDataFlow +import com.ivy.core.ui.algorithm.trnhistory.data.PeriodDataUi +import com.ivy.data.Value +import com.ivy.data.transaction.TransactionType +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.navigation.Navigator +import com.ivy.navigation.destinations.Destination +import com.ivy.navigation.destinations.transaction.NewTransaction +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onStart +import javax.inject.Inject + +@HiltViewModel +class HomeViewModel @Inject constructor( + private val balanceFlow: TotalBalanceFlow, + private val periodDataFlow: PeriodDataFlow, + private val navigator: Navigator, +) : SimpleFlowViewModel() { + private val addTransactionModal = IvyModal() + + override val initialUi = HomeStateUi( + balance = ValueUi(amount = "0.0", currency = ""), + income = ValueUi(amount = "0.0", currency = ""), + expense = ValueUi(amount = "0.0", currency = ""), + trnListItems = emptyList(), + + hideBalance = false, + bottomBarVisible = true, + + addTransactionModal = addTransactionModal, + ) + + private val overrideShowBalance = MutableStateFlow(false) + private val bottomBarVisible = MutableStateFlow(initialUi.bottomBarVisible) + + // region UI flow + override val uiFlow: Flow = combine( + immediateBalanceFlow(), immediatePeriodDataFlow(), bottomBarVisible + ) { balance, trnsList, bottomBarVisible -> + HomeStateUi( + balance = formatBalance(balance), + income = trnsList.periodIncome, + expense = trnsList.periodExpense, + trnListItems = trnsList.items, + + hideBalance = false, // TODO: Implement hide balance + bottomBarVisible = bottomBarVisible, + + addTransactionModal = addTransactionModal, + ) + } + + private fun immediateBalanceFlow() = balanceFlow( + TotalBalanceFlow.Input(withExcluded = false) + ).onStart { + emit(Value(amount = 0.0, currency = "")) + } + + private fun immediatePeriodDataFlow() = periodDataFlow( + PeriodDataFlow.Input.All + ).onStart { + emit( + PeriodDataUi( + periodIncome = ValueUi("", ""), + periodExpense = ValueUi("", ""), + items = emptyList() + ) + ) + } + + private fun formatBalance(balance: Value): ValueUi = format( + value = balance, + shortenFiat = balance.amount > 10_000 + ) + // endregion + + // region Event Handling + override suspend fun handleEvent(event: HomeEvent) = when (event) { + HomeEvent.AddExpense -> handleAddExpense() + HomeEvent.AddIncome -> handleAddIncome() + HomeEvent.AddTransfer -> handleAddTransfer() + HomeEvent.BalanceClick -> handleBalanceClick() + HomeEvent.HiddenBalanceClick -> handleHiddenBalanceClick() + HomeEvent.ExpenseClick -> handleExpenseClick() + HomeEvent.IncomeClick -> handleIncomeClick() + HomeEvent.MoreClick -> handleMoreClick() + is HomeEvent.BottomBar -> handleBottomBarEvents(event) + } + + private fun handleBottomBarEvents(event: HomeEvent.BottomBar) { + when (event) { + HomeEvent.BottomBar.AccountsClick -> { + navigator.navigate(Destination.accounts.destination(Unit)) + } + HomeEvent.BottomBar.AddClick -> { + addTransactionModal.show() + } + HomeEvent.BottomBar.Hide -> { + bottomBarVisible.value = false + } + HomeEvent.BottomBar.Show -> { + bottomBarVisible.value = true + } + } + } + + private fun handleAddTransfer() { + navigator.navigate(Destination.newTransfer.destination(Unit)) + } + + private fun handleAddIncome() { + navigator.navigate( + Destination.newTransaction.destination( + NewTransaction.Arg(trnType = TransactionType.Income) + ) + ) + } + + private fun handleAddExpense() { + navigator.navigate( + Destination.newTransaction.destination( + NewTransaction.Arg(trnType = TransactionType.Expense) + ) + ) + } + + private fun handleBalanceClick() { + // TODO: Implement + } + + private fun handleExpenseClick() { + // TODO: Implement + } + + private fun handleIncomeClick() { + // TODO: Implement + } + + private suspend fun handleHiddenBalanceClick() { + overrideShowBalance.value = true + delay(3_000L) + overrideShowBalance.value = false + } + + private fun handleMoreClick() { + navigator.navigate(Destination.moreMenu.destination(Unit)) + } + // endregion +} \ No newline at end of file diff --git a/main/home/src/main/java/com/ivy/home/components/Balance.kt b/main/home/src/main/java/com/ivy/home/components/Balance.kt new file mode 100644 index 0000000..15a35d2 --- /dev/null +++ b/main/home/src/main/java/com/ivy/home/components/Balance.kt @@ -0,0 +1,89 @@ +package com.ivy.home.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.dummyValueUi +import com.ivy.core.ui.value.AmountCurrency +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.H1Second +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.util.ComponentPreview + +// region Balance +@Composable +internal fun Balance( + balance: ValueUi, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Row( + modifier = modifier + .padding(horizontal = 24.dp) + .clip(UI.shapes.rounded) + .clickable(onClick = onClick), + verticalAlignment = Alignment.CenterVertically + ) { + H1Second( + text = balance.currency, + fontWeight = FontWeight.Normal, + ) + SpacerHor(width = 8.dp) + H1Second( + text = balance.amount, + modifier = Modifier.testTag("balance_amount"), + fontWeight = FontWeight.Bold, + ) + } +} +// endregion + +// region Balance Mini +@Composable +internal fun BalanceMini( + balance: ValueUi, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Row( + modifier = modifier + .clip(UI.shapes.rounded) + .clickable(onClick = onClick), + verticalAlignment = Alignment.CenterVertically + ) { + AmountCurrency(balance) + } +} +// endregion + + +// region Preview +@Preview +@Composable +private fun Preview_Balance() { + ComponentPreview { + Balance(balance = dummyValueUi("15,300.87")) { + + } + } +} + +@Preview +@Composable +private fun Preview_BalanceMini() { + ComponentPreview { + BalanceMini(balance = dummyValueUi("15,300.87")) { + + } + } +} +// endregion diff --git a/main/home/src/main/java/com/ivy/home/components/IncomeExpense.kt b/main/home/src/main/java/com/ivy/home/components/IncomeExpense.kt new file mode 100644 index 0000000..ce1c4f9 --- /dev/null +++ b/main/home/src/main/java/com/ivy/home/components/IncomeExpense.kt @@ -0,0 +1,111 @@ +package com.ivy.home.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.dummyValueUi +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.rememberContrast +import com.ivy.design.l1_buildingBlocks.* +import com.ivy.design.util.ComponentPreview +import com.ivy.resources.R + +@Composable +internal fun IncomeExpense( + income: ValueUi, + expense: ValueUi, + onIncomeClick: () -> Unit, + onExpenseClick: () -> Unit, +) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Card( + modifier = Modifier.weight(1f), + icon = R.drawable.ic_income, + bgColor = UI.colors.green, + text = stringResource(R.string.income), + value = income, + onClick = onIncomeClick, + ) + SpacerHor(width = 12.dp) + Card( + modifier = Modifier.weight(1f), + icon = R.drawable.ic_expense, + bgColor = UI.colors.red, + text = stringResource(R.string.expense), + value = expense, + onClick = onExpenseClick, + ) + } +} + +@Composable +private fun Card( + @DrawableRes + icon: Int, + bgColor: Color, + text: String, + value: ValueUi, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Column( + modifier = modifier + .background(bgColor, UI.shapes.rounded) + .clip(UI.shapes.rounded) + .clickable(onClick = onClick) + .padding(all = 12.dp), + ) { + val textColor = rememberContrast(bgColor) + Row(verticalAlignment = Alignment.CenterVertically) { + IconRes(icon = icon, tint = textColor) + SpacerHor(width = 4.dp) + Caption(text = text, color = textColor) + } + SpacerVer(height = 4.dp) + // Amount + B1Second( + text = value.amount, + modifier = Modifier + .testTag("amount") + .padding(start = 8.dp), + fontWeight = FontWeight.Bold, + color = textColor, + ) + } +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + ComponentPreview { + IncomeExpense( + income = dummyValueUi("168.32k"), + expense = dummyValueUi("9050.14"), + onIncomeClick = {}, + onExpenseClick = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/main/home/src/main/java/com/ivy/home/components/MoreMenuButton.kt b/main/home/src/main/java/com/ivy/home/components/MoreMenuButton.kt new file mode 100644 index 0000000..9a24bb8 --- /dev/null +++ b/main/home/src/main/java/com/ivy/home/components/MoreMenuButton.kt @@ -0,0 +1,36 @@ +package com.ivy.home.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview +import com.ivy.home.R + + +@Composable +internal fun MoreMenuButton( + onClick: () -> Unit +) { + IvyButton( + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = null, + icon = R.drawable.ic_settings, + onClick = onClick, + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview() { + ComponentPreview { + MoreMenuButton {} + } +} +// endregion \ No newline at end of file diff --git a/main/home/src/main/java/com/ivy/home/modal/AddTransactionModal.kt b/main/home/src/main/java/com/ivy/home/modal/AddTransactionModal.kt new file mode 100644 index 0000000..e7da3dc --- /dev/null +++ b/main/home/src/main/java/com/ivy/home/modal/AddTransactionModal.kt @@ -0,0 +1,91 @@ +package com.ivy.home.modal + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.previewModal +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview +import com.ivy.resources.R + +@Composable +internal fun BoxScope.AddTransactionModal( + modal: IvyModal, + onAddTransfer: () -> Unit, + onAddIncome: () -> Unit, + onAddExpense: () -> Unit, +) { + Modal( + modal = modal, + actions = {} + ) { + Title(text = "Add transaction") + SpacerVer(height = 24.dp) + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = stringResource(R.string.transfer), + icon = R.drawable.ic_transfer, + onClick = { + modal.hide() + onAddTransfer() + } + ) + SpacerVer(height = 12.dp) + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Custom(UI.colors.green), + text = stringResource(R.string.income), + icon = R.drawable.ic_income, + onClick = { + modal.hide() + onAddIncome() + } + ) + SpacerVer(height = 12.dp) + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Custom(UI.colors.red), + text = stringResource(R.string.expense), + icon = R.drawable.ic_expense, + onClick = { + modal.hide() + onAddExpense() + } + ) + SpacerVer(height = 16.dp) + } +} + + +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = previewModal() + AddTransactionModal( + modal = modal, + onAddTransfer = {}, + onAddIncome = {}, + onAddExpense = {}, + ) + } +} \ No newline at end of file diff --git a/main/more-menu/.gitignore b/main/more-menu/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/main/more-menu/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/main/more-menu/README.md b/main/more-menu/README.md new file mode 100644 index 0000000..e1d0f1a --- /dev/null +++ b/main/more-menu/README.md @@ -0,0 +1,15 @@ +# 🚧 Module under construction... + +If it hardly works, it's filled with bad code and anti-patterns anyway... + +### To see how a proper should look like refer to: + +- **[:core](../core)**: responsible for Ivy Wallet's domain +- **[:home](../home/)**: Ivy wallet's home screen. + +Apologies for the inconvenience! I'm a solo dev + the help of our awesome Ivy contributors. If you +want to support us: + +1. Star our repo. + [![GitHub Repo stars](https://img.shields.io/github/stars/Ivy-Apps/ivy-wallet?style=social)](https://github.com/Ivy-Apps/ivy-wallet/stargazers) +2. Have a look at our [Contributors Guide](../CONTRIBUTING.md). \ No newline at end of file diff --git a/main/more-menu/build.gradle.kts b/main/more-menu/build.gradle.kts new file mode 100644 index 0000000..491ea50 --- /dev/null +++ b/main/more-menu/build.gradle.kts @@ -0,0 +1,17 @@ +import com.ivy.buildsrc.Hilt + +apply() + +plugins { + `android-library` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":design-system")) + implementation(project(":core:ui")) + implementation(project(":core:domain")) + implementation(project(":core:data-model")) + implementation(project(":navigation")) +} \ No newline at end of file diff --git a/main/more-menu/src/main/AndroidManifest.xml b/main/more-menu/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cfc75bf --- /dev/null +++ b/main/more-menu/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/main/more-menu/src/main/java/com/ivy/menu/HomeMoreMenuScreen.kt b/main/more-menu/src/main/java/com/ivy/menu/HomeMoreMenuScreen.kt new file mode 100644 index 0000000..b9d0930 --- /dev/null +++ b/main/more-menu/src/main/java/com/ivy/menu/HomeMoreMenuScreen.kt @@ -0,0 +1,136 @@ +package com.ivy.menu + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.uiStatePreviewSafe +import com.ivy.data.Theme +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.ColumnRoot +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l1_buildingBlocks.SpacerWeight +import com.ivy.design.l2_components.modal.CloseButton +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.hiltViewModelPreviewSafe + +// TODO: Not implemented, yet => Re-work it + +@Composable +fun BoxScope.HomeMoreMenuScreen() { + val viewModel: HomeMoreMenuViewModel? = hiltViewModelPreviewSafe() + val state = uiStatePreviewSafe(viewModel = viewModel, preview = ::previewState) + + ColumnRoot( + modifier = Modifier + .background(UI.colors.pure) + ) { + SpacerWeight(weight = 1f) + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = "Categories", + icon = null + ) { + viewModel?.onEvent(MoreMenuEvent.CategoriesClick) + } + SpacerVer(height = 16.dp) + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = "Settings", + icon = null + ) { + viewModel?.onEvent(MoreMenuEvent.SettingsClick) + } + SpacerVer(height = 16.dp) + ToggleTheme( + theme = state.theme, + onThemeChange = { + viewModel?.onEvent(MoreMenuEvent.ThemeChange(it)) + } + ) + SpacerWeight(weight = 1f) + CloseButton( + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = { + viewModel?.onEvent(MoreMenuEvent.Close) + } + ) + SpacerVer(height = 48.dp) + } +} + +@Composable +private fun ToggleTheme( + theme: Theme, + onThemeChange: (Theme) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IvyButton( + modifier = Modifier.weight(1f), + size = ButtonSize.Big, + visibility = if (theme == Theme.Light) Visibility.High else Visibility.Medium, + feeling = Feeling.Positive, + text = "Light", + icon = null, + ) { + onThemeChange(Theme.Light) + } + SpacerHor(width = 12.dp) + IvyButton( + modifier = Modifier.weight(1f), + size = ButtonSize.Big, + visibility = if (theme == Theme.Dark) Visibility.High else Visibility.Medium, + feeling = Feeling.Positive, + text = "Dark", + icon = null, + ) { + onThemeChange(Theme.Dark) + } + SpacerHor(width = 12.dp) + IvyButton( + modifier = Modifier.weight(1f), + size = ButtonSize.Big, + visibility = if (theme == Theme.Auto) Visibility.High else Visibility.Medium, + feeling = Feeling.Positive, + text = "Auto", + icon = null, + ) { + onThemeChange(Theme.Auto) + } + } +} + + +@Preview +@Composable +private fun HomeMoreMenuPreview() { + IvyPreview { + HomeMoreMenuScreen() + } +} + +private fun previewState() = MoreMenuState( + theme = Theme.Auto, +) diff --git a/main/more-menu/src/main/java/com/ivy/menu/HomeMoreMenuViewModel.kt b/main/more-menu/src/main/java/com/ivy/menu/HomeMoreMenuViewModel.kt new file mode 100644 index 0000000..bc96b31 --- /dev/null +++ b/main/more-menu/src/main/java/com/ivy/menu/HomeMoreMenuViewModel.kt @@ -0,0 +1,51 @@ +package com.ivy.menu + +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.action.settings.theme.ThemeFlow +import com.ivy.core.domain.action.settings.theme.WriteThemeAct +import com.ivy.data.Theme +import com.ivy.navigation.Navigator +import com.ivy.navigation.destinations.Destination +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@HiltViewModel +class HomeMoreMenuViewModel @Inject constructor( + private val navigator: Navigator, + private val themeFlow: ThemeFlow, + private val writeThemeAct: WriteThemeAct, +) : SimpleFlowViewModel() { + override val initialUi = MoreMenuState( + theme = Theme.Auto + ) + + override val uiFlow: Flow = themeFlow(Unit).map { theme -> + MoreMenuState( + theme = theme + ) + } + + + // region Event Handling + override suspend fun handleEvent(event: MoreMenuEvent) = when (event) { + MoreMenuEvent.CategoriesClick -> handleCategoriesClick() + MoreMenuEvent.SettingsClick -> handleSettingsClick() + is MoreMenuEvent.ThemeChange -> handleThemeChange(event) + MoreMenuEvent.Close -> navigator.back() + } + + private fun handleCategoriesClick() { + navigator.navigate(Destination.categories.destination(Unit)) + } + + private fun handleSettingsClick() { + navigator.navigate(Destination.settings.destination(Unit)) + } + + private suspend fun handleThemeChange(event: MoreMenuEvent.ThemeChange) { + writeThemeAct(event.theme) + } + // endregion +} \ No newline at end of file diff --git a/main/more-menu/src/main/java/com/ivy/menu/MoreMenuEvent.kt b/main/more-menu/src/main/java/com/ivy/menu/MoreMenuEvent.kt new file mode 100644 index 0000000..96a7268 --- /dev/null +++ b/main/more-menu/src/main/java/com/ivy/menu/MoreMenuEvent.kt @@ -0,0 +1,10 @@ +package com.ivy.menu + +import com.ivy.data.Theme + +sealed interface MoreMenuEvent { + object CategoriesClick : MoreMenuEvent + object SettingsClick : MoreMenuEvent + data class ThemeChange(val theme: Theme) : MoreMenuEvent + object Close : MoreMenuEvent +} \ No newline at end of file diff --git a/main/more-menu/src/main/java/com/ivy/menu/MoreMenuState.kt b/main/more-menu/src/main/java/com/ivy/menu/MoreMenuState.kt new file mode 100644 index 0000000..481cac9 --- /dev/null +++ b/main/more-menu/src/main/java/com/ivy/menu/MoreMenuState.kt @@ -0,0 +1,9 @@ +package com.ivy.menu + +import androidx.compose.runtime.Immutable +import com.ivy.data.Theme + +@Immutable +data class MoreMenuState( + val theme: Theme, +) \ No newline at end of file diff --git a/math/.gitignore b/math/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/math/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/math/README.md b/math/README.md new file mode 100644 index 0000000..f93c152 --- /dev/null +++ b/math/README.md @@ -0,0 +1,7 @@ +# Math + +The `:math` module is responsible for: + +- Parsing and evaluating mathematical expressions. +- Syntax checking and suggestions when typing expressions. +- Common math functions that aren't implemented in Kotlin's standard library. \ No newline at end of file diff --git a/math/build.gradle.kts b/math/build.gradle.kts new file mode 100644 index 0000000..2e0943d --- /dev/null +++ b/math/build.gradle.kts @@ -0,0 +1,16 @@ +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":parser")) + Testing() +} \ No newline at end of file diff --git a/math/src/main/AndroidManifest.xml b/math/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a9430f8 --- /dev/null +++ b/math/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/math/src/main/java/com/ivy/math/EvaluateExpression.kt b/math/src/main/java/com/ivy/math/EvaluateExpression.kt new file mode 100644 index 0000000..0c97853 --- /dev/null +++ b/math/src/main/java/com/ivy/math/EvaluateExpression.kt @@ -0,0 +1,46 @@ +package com.ivy.math + +import com.ivy.math.calculator.bracketsClosed +import timber.log.Timber + +fun evaluate(expression: String): Double? { + val parser = expressionParser() + val fixedExpression = tryFixExpression(normalize(expression)) + val result = parser(fixedExpression) + val expressionTree = result.firstOrNull() + ?.takeIf { it.leftover.isEmpty() }?.value ?: return null + Timber.d("Evaluating: ${expressionTree.print()}") + return expressionTree.eval() +} + +fun tryFixExpression(expression: String): String { + fun fixPartialBinaryOps(expression: String): String = when (expression.lastOrNull()) { + '+', '-', '*', '/' -> expression.dropLast(1) + else -> when { + expression.endsWith("()") -> fixPartialBinaryOps(expression.dropLast(2)) + expression.endsWith("(") -> fixPartialBinaryOps(expression.dropLast(1)) + else -> expression + } + } + + fun fixLeadingPlus(expression: String): String = if (expression.firstOrNull() == '+') + expression.drop(1) else expression + + var fixBrackets = fixLeadingPlus(expression) + .let(::fixPartialBinaryOps) + .replace("(+", "(") + while (!bracketsClosed(fixBrackets)) { + fixBrackets += ')' + } + return fixBrackets.replace("()", "") // fix empty brackets +} + +/** + * Returns a normalized expression by: + * - removing grouping separators for thousands + * - replacing local decimal separator with '.' + * 1,032.55 => 1032.55 + */ +fun normalize(expression: String): String = expression + .replace(localGroupingSeparator().toString(), "") + .replace(localDecimalSeparator().toString(), ".") diff --git a/math/src/main/java/com/ivy/math/ExpressionParser.kt b/math/src/main/java/com/ivy/math/ExpressionParser.kt new file mode 100644 index 0000000..8c53d8d --- /dev/null +++ b/math/src/main/java/com/ivy/math/ExpressionParser.kt @@ -0,0 +1,110 @@ +package com.ivy.math + +import arrow.core.NonEmptyList +import arrow.core.nonEmptyListOf +import com.ivy.parser.* +import com.ivy.parser.common.number + +sealed interface TreeNode { + fun print(): String + fun eval(): Double +} + +class Add(private val things: NonEmptyList) : TreeNode { + override fun print(): String = things.map { "(${it.print()})" } + .joinToString(separator = "+") + + override fun eval(): Double = things.map { it.eval() }.sum() +} + +class Multiply(private val left: TreeNode, private val right: TreeNode) : TreeNode { + override fun print(): String = "(${left.print()}*${right.print()}.)" + + override fun eval(): Double = left.eval() * right.eval() +} + +class Divide(private val left: TreeNode, private val right: TreeNode) : TreeNode { + override fun print(): String = "(${left.print()}/${right.print()}.)" + + override fun eval(): Double = left.eval() / right.eval() +} + +class Percent(private val expr: TreeNode) : TreeNode { + override fun print(): String = "(${expr.print()})%" + + override fun eval(): Double = expr.eval() / 100.0 +} + +class Negate(private val node: TreeNode) : TreeNode { + override fun print(): String = "(-${node.print()})" + + override fun eval(): Double = -(node.eval()) +} + +class Number(private val decimal: Double) : TreeNode { + override fun print(): String = decimal.toString() + + override fun eval(): Double = decimal +} + + +/** + * Evaluates an arbitrary mathematical expression to double. + */ +fun expressionParser(): Parser = expr() + +private fun expr(): Parser = term().apply { x -> + oneOrMany( + (char('+') or char('-')).apply { sign -> + term().apply { y -> + pure( + when (sign) { + '+' -> y + '-' -> Negate(y) + else -> error("Impossible") + } + ) + } + } + ).apply { ys -> + pure(Add(nonEmptyListOf(x, *ys.toTypedArray()))) + } +} or term() + +private fun term(): Parser = factor().apply { x -> + char('*').apply { + term().apply { y -> + pure(Multiply(x, y)) + } + } +} or factor().apply { x -> + char('/').apply { + term().apply { y -> + pure(Divide(x, y)) + } + } +} or factor() + +private fun factor(): Parser = number().apply { x -> + char('%').apply { + pure(Percent(Number(x))) + } +} or number().apply { num -> + pure(Number(num)) +} or char('(').apply { + expr().apply { x -> + string(")%").apply { + pure(Percent(x)) + } + } +} or char('(').apply { + expr().apply { x -> + char(')').apply { + pure(x) + } + } +} or char('-').apply { + factor().apply { x -> + pure(Negate(x)) + } +} \ No newline at end of file diff --git a/math/src/main/java/com/ivy/math/FormatNumber.kt b/math/src/main/java/com/ivy/math/FormatNumber.kt new file mode 100644 index 0000000..c5bf867 --- /dev/null +++ b/math/src/main/java/com/ivy/math/FormatNumber.kt @@ -0,0 +1,10 @@ +package com.ivy.math + +import java.text.DecimalFormat + +/** + * Formats number like a calculator would do. + * Precision is set to 6 decimals. + */ +fun formatNumber(number: Double): String = + DecimalFormat("###,###,##0.${"#".repeat(6)}").format(number) \ No newline at end of file diff --git a/math/src/main/java/com/ivy/math/LocalSeparators.kt b/math/src/main/java/com/ivy/math/LocalSeparators.kt new file mode 100644 index 0000000..8286fb3 --- /dev/null +++ b/math/src/main/java/com/ivy/math/LocalSeparators.kt @@ -0,0 +1,9 @@ +package com.ivy.math + +import java.text.DecimalFormatSymbols + +fun localDecimalSeparator(): Char = + DecimalFormatSymbols.getInstance().decimalSeparator + +fun localGroupingSeparator(): Char = + DecimalFormatSymbols.getInstance().groupingSeparator \ No newline at end of file diff --git a/math/src/main/java/com/ivy/math/calculator/AppendCalculatorOperator.kt b/math/src/main/java/com/ivy/math/calculator/AppendCalculatorOperator.kt new file mode 100644 index 0000000..ef8e223 --- /dev/null +++ b/math/src/main/java/com/ivy/math/calculator/AppendCalculatorOperator.kt @@ -0,0 +1,83 @@ +package com.ivy.math.calculator + +import com.ivy.math.expressionParser +import com.ivy.math.normalize +import com.ivy.parser.common.number + +/** + * Appends calculator option to an expression by following expression syntax. + * If the calculator option isn't valid it won't be added. + * @return a new expression with the selected calculator option applied. + */ +fun appendTo(expression: String, operator: CalculatorOperator): String = when (operator) { + CalculatorOperator.Plus -> expression.appendPlusOrMinus('+') + CalculatorOperator.Minus -> expression.appendPlusOrMinus('-') + CalculatorOperator.Multiply -> expression.appendBinaryOperator('*') + CalculatorOperator.Divide -> expression.appendBinaryOperator('/') + CalculatorOperator.Brackets -> expression.brackets() + CalculatorOperator.Percent -> expression.percent() +} + +private fun String.appendPlusOrMinus(operator: Char): String = when (this.lastOrNull()) { + '-', '+' -> this.dropLast(1).plus(operator) + else -> this.plus(operator) +} + +private fun String.appendBinaryOperator(operator: Char): String { + when (this.lastOrNull()) { + // binary operators can be applied to '%' and ')' + '%', ')' -> return this.plus(operator) + } + // binary operators require a number on the left + return if (endWithDecimal(this)) this.plus(operator) else this +} + +fun bracketsClosed(expression: String): Boolean = + expression.count { it == '(' } == expression.count { it == ')' } + + +private fun String.brackets(): String { + fun determineBracket(expression: String): String { + if (expression.isEmpty()) return "(" + val closed = bracketsClosed(expression) + return when (expression.lastOrNull()) { + '+', '-', '(', '/', '*' -> "(" + ')' -> if (closed) "*(" else ")" + else -> { + if (!closed) return ")" + val parsed = expressionParser().invoke(expression) + if (parsed.isNotEmpty()) return "*(" + ")" + } + } + } + + return this + determineBracket(this) +} + +private fun String.percent(): String { + fun allowPercent(expression: String): Boolean = when (expression.lastOrNull()) { + ')' -> true + null, '+', '-', '*', '/', '%' -> false + else -> endWithDecimal(this) + } + + return if (allowPercent(this)) this.plus('%') else this +} + +private fun endWithDecimal(expression: String): Boolean { + /** + * Extracts the last number from an expression. + * 10+15.5 => 15.5 + */ + fun lastNumber(expression: String): String? { + val lastChar = expression.lastOrNull() ?: return null + return lastChar + (lastNumber(expression.dropLast(1)) ?: "") + } + + val normalizedExpression = normalize(expression) + val lastNumber = lastNumber(normalizedExpression) + ?: return false // binary expressions require a number on the left! + val decimalResult = number().invoke(lastNumber) + return decimalResult.isNotEmpty() // parsed successfully a decimal +} \ No newline at end of file diff --git a/math/src/main/java/com/ivy/math/calculator/AppendDecimalSeparator.kt b/math/src/main/java/com/ivy/math/calculator/AppendDecimalSeparator.kt new file mode 100644 index 0000000..ce669f6 --- /dev/null +++ b/math/src/main/java/com/ivy/math/calculator/AppendDecimalSeparator.kt @@ -0,0 +1,36 @@ +package com.ivy.math.calculator + +fun appendDecimalSeparator( + expression: String, decimalSeparator: Char +): String { + // this function returns whether the last number in expression already has decimal separator, + // the function splits the expression on anything which is not a decimal or + // a decimal separator using the regular expression `[^0-9^${decimalSeparator}]` + // (^0-9 is for selecting anything which is not a digit and ^${decimalSeparator} + // is for selecting anything which is not a decimal separator), to filter out all operators, + // brackets from the expression and get only numbers in expression, + // then checks if the last number contains decimal separator. + // for ex. for expression "1.01+(1.01-2)+1.01" -> split -> [1.01, , 1.01, 2, , 1.01] -> + // last -> 1.01 -> contains decimal separator? -> true + fun alreadyAddedDecimal(expression: String): Boolean = + expression.split(Regex("[^0-9^${decimalSeparator}]")).last() + .contains(decimalSeparator) + + fun allowDecimalSeparator(expression: String): Boolean = + !alreadyAddedDecimal(expression) && when (expression.lastOrNull()) { + ')', decimalSeparator, '%' -> false + else -> true + } + + fun appendDecimalSeparator(expression: String): String { + val lastChar = expression.lastOrNull() + return when { + lastChar == null -> expression.plus("0.") + !lastChar.isDigit() -> expression.plus("0.") + else -> expression.plus('.') + } + } + + return if (allowDecimalSeparator(expression)) + appendDecimalSeparator(expression) else expression +} \ No newline at end of file diff --git a/math/src/main/java/com/ivy/math/calculator/AppendNumberDigit.kt b/math/src/main/java/com/ivy/math/calculator/AppendNumberDigit.kt new file mode 100644 index 0000000..6049191 --- /dev/null +++ b/math/src/main/java/com/ivy/math/calculator/AppendNumberDigit.kt @@ -0,0 +1,9 @@ +package com.ivy.math.calculator + +fun appendTo(expression: String, digit: Int): String { + val thingToAppend = when (expression.lastOrNull()) { + ')', '%' -> "*$digit" + else -> "$digit" + } + return expression.plus(thingToAppend) +} \ No newline at end of file diff --git a/math/src/main/java/com/ivy/math/calculator/CalculatorOperator.kt b/math/src/main/java/com/ivy/math/calculator/CalculatorOperator.kt new file mode 100644 index 0000000..f5f1f4f --- /dev/null +++ b/math/src/main/java/com/ivy/math/calculator/CalculatorOperator.kt @@ -0,0 +1,5 @@ +package com.ivy.math.calculator + +enum class CalculatorOperator { + Plus, Minus, Multiply, Divide, Brackets, Percent +} \ No newline at end of file diff --git a/math/src/main/java/com/ivy/math/calculator/ExpressionUtil.kt b/math/src/main/java/com/ivy/math/calculator/ExpressionUtil.kt new file mode 100644 index 0000000..abdf6a7 --- /dev/null +++ b/math/src/main/java/com/ivy/math/calculator/ExpressionUtil.kt @@ -0,0 +1,42 @@ +package com.ivy.math.calculator + +import com.ivy.math.localDecimalSeparator +import com.ivy.math.normalize +import java.text.DecimalFormat + +/** + * @return whether the calculation result is worth to be displayed. + */ +fun hasObviousResult(expression: String, value: Double?): Boolean = + when (expression.lastOrNull()) { + '+', '-', '*', '/' -> expression.dropLast(1).none { + // It's obvious if it has any preceding calculations + when (it) { + '+', '-', '*', '/' -> true + else -> false + } + } + else -> normalize(expression).toDoubleOrNull() == value + } + +fun beautify(expression: String): String? { + fun formatInt(number: String): String = + number.toIntOrNull()?.let { DecimalFormat("###,###,###").format(it) } ?: number + + fun format(number: String): String = if (number.contains(localDecimalSeparator())) { + // format decimal + val (int, decimal) = number.split(localDecimalSeparator()) + "${formatInt(int)}.$decimal" + } else { + formatInt(number) + } + + if (expression.isEmpty()) return null + var beautified = expression + expression.split("+", "-", "*", "/", "(", ")", "%") + .forEach { numberStr -> + val formattedNum = format(numberStr) + beautified = beautified.replace(numberStr, formattedNum) + } + return beautified +} \ No newline at end of file diff --git a/navigation/.gitignore b/navigation/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/navigation/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/navigation/README.md b/navigation/README.md new file mode 100644 index 0000000..96912a9 --- /dev/null +++ b/navigation/README.md @@ -0,0 +1,5 @@ +# Navigation + +Jetpack Compose Navigation implementation supporting all navigation in Ivy Wallet. + +Exposes the `Navigator` object used for navigating between screens in the app. \ No newline at end of file diff --git a/navigation/build.gradle.kts b/navigation/build.gradle.kts new file mode 100644 index 0000000..761f58a --- /dev/null +++ b/navigation/build.gradle.kts @@ -0,0 +1,15 @@ +import com.ivy.buildsrc.Compose +import com.ivy.buildsrc.Hilt + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + Compose(api = false) +} \ No newline at end of file diff --git a/navigation/src/main/AndroidManifest.xml b/navigation/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bc46c00 --- /dev/null +++ b/navigation/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/NavGraph.kt b/navigation/src/main/java/com/ivy/navigation/NavGraph.kt new file mode 100644 index 0000000..4bb9121 --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/NavGraph.kt @@ -0,0 +1,6 @@ +package com.ivy.navigation + +interface NavGraph { + val route: String + val startDestination: String +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/NavigationRoot.kt b/navigation/src/main/java/com/ivy/navigation/NavigationRoot.kt new file mode 100644 index 0000000..fb01df8 --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/NavigationRoot.kt @@ -0,0 +1,79 @@ +package com.ivy.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.ivy.navigation.destinations.Destination +import com.ivy.navigation.destinations.imports.ImportBackup +import com.ivy.navigation.destinations.main.Accounts +import com.ivy.navigation.destinations.main.Categories +import com.ivy.navigation.destinations.main.Home +import com.ivy.navigation.destinations.main.MoreMenu +import com.ivy.navigation.destinations.other.AddFrame +import com.ivy.navigation.destinations.other.ExchangeRate +import com.ivy.navigation.destinations.settings.Settings +import com.ivy.navigation.graph.* +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun NavigationRoot( + navigator: Navigator, + onboardingScreens: OnboardingScreens, + home: @Composable () -> Unit, + accounts: @Composable () -> Unit, + moreMenu: @Composable () -> Unit, + categories: @Composable () -> Unit, + settings: @Composable () -> Unit, + transactionScreens: TransactionScreens, + addFrame: @Composable () -> Unit, + importBackup: @Composable () -> Unit, + exchangeRates: @Composable () -> Unit, + debugScreens: DebugScreens +) { + val navController = rememberNavController() + LaunchedEffect(Unit) { + navigator.actions.collectLatest { action -> + when (action) { + Navigator.Action.Back -> navController.popBackStack() + is Navigator.Action.Navigate -> navController.navigate( + route = action.destination, + builder = action.navOptions + ) + } + } + } + NavHost( + navController = navController, + startDestination = Destination.home.route + ) { + onboardingGraph(onboardingScreens) + composable(Home.route) { + home() + } + composable(Accounts.route) { + accounts() + } + composable(MoreMenu.route) { + moreMenu() + } + composable(Categories.route) { + categories() + } + composable(Settings.route) { + settings() + } + transactionScreens(transactionScreens) + debug(debugScreens) + composable(AddFrame.route) { + addFrame() + } + composable(ImportBackup.route) { + importBackup() + } + composable(ExchangeRate.route) { + exchangeRates() + } + } +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/Navigator.kt b/navigation/src/main/java/com/ivy/navigation/Navigator.kt new file mode 100644 index 0000000..606b8df --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/Navigator.kt @@ -0,0 +1,37 @@ +package com.ivy.navigation + +import androidx.compose.runtime.Stable +import androidx.navigation.NavOptionsBuilder +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Stable +@Singleton +class Navigator @Inject constructor() { + private val _actions = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 10 + ) + internal val actions: Flow = _actions + + fun navigate(destination: DestinationRoute, navOptions: NavOptionsBuilder.() -> Unit = {}) { + _actions.tryEmit( + Action.Navigate(destination = destination, navOptions = navOptions) + ) + } + + fun back() { + _actions.tryEmit(Action.Back) + } + + internal sealed class Action { + data class Navigate( + val destination: DestinationRoute, + val navOptions: NavOptionsBuilder.() -> Unit + ) : Action() + + object Back : Action() + } +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/Screen.kt b/navigation/src/main/java/com/ivy/navigation/Screen.kt new file mode 100644 index 0000000..af2bd5b --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/Screen.kt @@ -0,0 +1,24 @@ +package com.ivy.navigation + +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry + +abstract class NoArgsScreen : NavNode, NavDestination { + override val arguments: List = emptyList() + override fun parse(entry: NavBackStackEntry) {} + override fun destination(arg: Unit): DestinationRoute = route +} + +interface Screen : NavNode, NavDestination + +interface NavNode { + val route: String + val arguments: List +} + +typealias DestinationRoute = String + +interface NavDestination { + fun destination(arg: Arg): DestinationRoute + fun parse(entry: NavBackStackEntry): Arg +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/Destination.kt b/navigation/src/main/java/com/ivy/navigation/destinations/Destination.kt new file mode 100644 index 0000000..5958031 --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/Destination.kt @@ -0,0 +1,45 @@ +package com.ivy.navigation.destinations + +import com.ivy.navigation.destinations.debug.DebugGraph +import com.ivy.navigation.destinations.imports.ImportBackup +import com.ivy.navigation.destinations.imports.ImportGraph +import com.ivy.navigation.destinations.main.Accounts +import com.ivy.navigation.destinations.main.Categories +import com.ivy.navigation.destinations.main.Home +import com.ivy.navigation.destinations.main.MoreMenu +import com.ivy.navigation.destinations.onboarding.OnboardingGraph +import com.ivy.navigation.destinations.other.AddFrame +import com.ivy.navigation.destinations.other.ExchangeRate +import com.ivy.navigation.destinations.settings.Settings +import com.ivy.navigation.destinations.transaction.* + +object Destination { + val onboarding = OnboardingGraph + val import = ImportGraph + + val importBackup = ImportBackup + + // region Main + val categories = Categories + val home = Home + val moreMenu = MoreMenu + val accounts = Accounts + // endregion + + // region Transaction + val transaction = Transaction + val newTransaction = NewTransaction + val transfer = Transfer + val newTransfer = NewTransfer + val accountTransactions = AccountTransactions + val categoryTransactions = CategoryTransactions + // endregion + + val settings = Settings + + val addFrame = AddFrame + + val exchangeRates = ExchangeRate + + val debug = DebugGraph +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/debug/DebugGraph.kt b/navigation/src/main/java/com/ivy/navigation/destinations/debug/DebugGraph.kt new file mode 100644 index 0000000..f6d3e57 --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/debug/DebugGraph.kt @@ -0,0 +1,11 @@ +package com.ivy.navigation.destinations.debug + +import com.ivy.navigation.NavGraph +import com.ivy.navigation.destinations.debug.screen.Test + +object DebugGraph : NavGraph { + override val route: String = "debug" + override val startDestination: String = Test.destination(Unit) + + val test = Test +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/debug/screen/Test.kt b/navigation/src/main/java/com/ivy/navigation/destinations/debug/screen/Test.kt new file mode 100644 index 0000000..69b974a --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/debug/screen/Test.kt @@ -0,0 +1,15 @@ +package com.ivy.navigation.destinations.debug.screen + +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import com.ivy.navigation.DestinationRoute +import com.ivy.navigation.Screen + +object Test : Screen { + override val route: String = "debug/test" + override val arguments: List = emptyList() + + override fun destination(arg: Unit): DestinationRoute = route + + override fun parse(entry: NavBackStackEntry) {} +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/imports/ImportApp.kt b/navigation/src/main/java/com/ivy/navigation/destinations/imports/ImportApp.kt new file mode 100644 index 0000000..913f277 --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/imports/ImportApp.kt @@ -0,0 +1,23 @@ +package com.ivy.navigation.destinations.imports + +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavType +import androidx.navigation.navArgument +import com.ivy.navigation.Screen +import com.ivy.navigation.util.stringArg + +object ImportApp : Screen { + private const val ARG_IMPORT_APP = "importApp" + + override val route: String = "import/{$ARG_IMPORT_APP}" + override val arguments: List = listOf( + navArgument(ARG_IMPORT_APP) { + nullable = false + type = NavType.StringType + } + ) + + override fun destination(arg: String): String = "import/$arg" + override fun parse(entry: NavBackStackEntry): String = entry.stringArg(ARG_IMPORT_APP) +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/imports/ImportBackup.kt b/navigation/src/main/java/com/ivy/navigation/destinations/imports/ImportBackup.kt new file mode 100644 index 0000000..29ebbd5 --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/imports/ImportBackup.kt @@ -0,0 +1,7 @@ +package com.ivy.navigation.destinations.imports + +import com.ivy.navigation.NoArgsScreen + +object ImportBackup : NoArgsScreen() { + override val route = "import-backup" +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/imports/ImportGraph.kt b/navigation/src/main/java/com/ivy/navigation/destinations/imports/ImportGraph.kt new file mode 100644 index 0000000..bffe30a --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/imports/ImportGraph.kt @@ -0,0 +1,12 @@ +package com.ivy.navigation.destinations.imports + +import com.ivy.navigation.NavGraph + +object ImportGraph : NavGraph { + override val route: String + get() = "import" + override val startDestination: String + get() = TODO() + + val importApp = ImportApp +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/main/Accounts.kt b/navigation/src/main/java/com/ivy/navigation/destinations/main/Accounts.kt new file mode 100644 index 0000000..678eda3 --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/main/Accounts.kt @@ -0,0 +1,7 @@ +package com.ivy.navigation.destinations.main + +import com.ivy.navigation.NoArgsScreen + +object Accounts : NoArgsScreen() { + override val route = "accounts" +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/main/Categories.kt b/navigation/src/main/java/com/ivy/navigation/destinations/main/Categories.kt new file mode 100644 index 0000000..a08c8e8 --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/main/Categories.kt @@ -0,0 +1,15 @@ +package com.ivy.navigation.destinations.main + +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import com.ivy.navigation.DestinationRoute +import com.ivy.navigation.Screen + +object Categories : Screen { + override val route: String = "categories" + override val arguments: List = emptyList() + + override fun parse(entry: NavBackStackEntry): Unit = Unit + + override fun destination(arg: Unit): DestinationRoute = route +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/main/Home.kt b/navigation/src/main/java/com/ivy/navigation/destinations/main/Home.kt new file mode 100644 index 0000000..27dc3f3 --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/main/Home.kt @@ -0,0 +1,7 @@ +package com.ivy.navigation.destinations.main + +import com.ivy.navigation.NoArgsScreen + +object Home : NoArgsScreen() { + override val route = "home" +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/main/MoreMenu.kt b/navigation/src/main/java/com/ivy/navigation/destinations/main/MoreMenu.kt new file mode 100644 index 0000000..be3d01c --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/main/MoreMenu.kt @@ -0,0 +1,7 @@ +package com.ivy.navigation.destinations.main + +import com.ivy.navigation.NoArgsScreen + +object MoreMenu : NoArgsScreen() { + override val route = "more-menu" +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/onboarding/OnboardingGraph.kt b/navigation/src/main/java/com/ivy/navigation/destinations/onboarding/OnboardingGraph.kt new file mode 100644 index 0000000..fa3ef9d --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/onboarding/OnboardingGraph.kt @@ -0,0 +1,17 @@ +package com.ivy.navigation.destinations.onboarding + +import com.ivy.navigation.NavGraph +import com.ivy.navigation.destinations.onboarding.screen.* + +object OnboardingGraph : NavGraph { + override val route = "onboarding" + override val startDestination + get() = debug.destination(Unit) + + val debug = OnboardingDebug + val loginOrOffline = LoginOffline + val importBackup = Backup + val setCurrency = SetCurrency + val addAccounts = AddAccounts + val addCategories = AddCategories +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/onboarding/screen/AddAccounts.kt b/navigation/src/main/java/com/ivy/navigation/destinations/onboarding/screen/AddAccounts.kt new file mode 100644 index 0000000..f1d8cd0 --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/onboarding/screen/AddAccounts.kt @@ -0,0 +1,13 @@ +package com.ivy.navigation.destinations.onboarding.screen + +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import com.ivy.navigation.Screen + +object AddAccounts : Screen { + override val route: String = "onboarding/accounts" + override val arguments: List = emptyList() + + override fun destination(arg: Unit): String = route + override fun parse(entry: NavBackStackEntry) {} +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/onboarding/screen/AddCategories.kt b/navigation/src/main/java/com/ivy/navigation/destinations/onboarding/screen/AddCategories.kt new file mode 100644 index 0000000..ea5a468 --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/onboarding/screen/AddCategories.kt @@ -0,0 +1,13 @@ +package com.ivy.navigation.destinations.onboarding.screen + +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import com.ivy.navigation.Screen + +object AddCategories : Screen { + override val route: String = "onboarding/categories" + override val arguments: List = emptyList() + + override fun destination(arg: Unit): String = route + override fun parse(entry: NavBackStackEntry) {} +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/onboarding/screen/Backup.kt b/navigation/src/main/java/com/ivy/navigation/destinations/onboarding/screen/Backup.kt new file mode 100644 index 0000000..b122b7f --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/onboarding/screen/Backup.kt @@ -0,0 +1,13 @@ +package com.ivy.navigation.destinations.onboarding.screen + +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import com.ivy.navigation.Screen + +object Backup : Screen { + override val route: String = "onboarding/backup" + override val arguments: List = emptyList() + + override fun destination(arg: Unit): String = route + override fun parse(entry: NavBackStackEntry) {} +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/onboarding/screen/LoginOffline.kt b/navigation/src/main/java/com/ivy/navigation/destinations/onboarding/screen/LoginOffline.kt new file mode 100644 index 0000000..fcc0402 --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/onboarding/screen/LoginOffline.kt @@ -0,0 +1,14 @@ +package com.ivy.navigation.destinations.onboarding.screen + +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import com.ivy.navigation.Screen + +object LoginOffline : Screen { + override val route: String = "onboarding/login" + override val arguments: List = emptyList() + + override fun destination(arg: Unit): String = route + + override fun parse(entry: NavBackStackEntry) {} +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/onboarding/screen/OnboardingDebug.kt b/navigation/src/main/java/com/ivy/navigation/destinations/onboarding/screen/OnboardingDebug.kt new file mode 100644 index 0000000..e06869e --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/onboarding/screen/OnboardingDebug.kt @@ -0,0 +1,15 @@ +package com.ivy.navigation.destinations.onboarding.screen + +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import com.ivy.navigation.DestinationRoute +import com.ivy.navigation.Screen + +object OnboardingDebug : Screen { + override val route = "onboarding/debug" + override val arguments: List = emptyList() + + override fun parse(entry: NavBackStackEntry) {} + + override fun destination(arg: Unit): DestinationRoute = route +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/onboarding/screen/SetCurrency.kt b/navigation/src/main/java/com/ivy/navigation/destinations/onboarding/screen/SetCurrency.kt new file mode 100644 index 0000000..259b11f --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/onboarding/screen/SetCurrency.kt @@ -0,0 +1,13 @@ +package com.ivy.navigation.destinations.onboarding.screen + +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import com.ivy.navigation.Screen + +object SetCurrency : Screen { + override val route: String = "onboarding/currency" + override val arguments: List = emptyList() + + override fun destination(arg: Unit): String = route + override fun parse(entry: NavBackStackEntry) {} +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/other/AddFrame.kt b/navigation/src/main/java/com/ivy/navigation/destinations/other/AddFrame.kt new file mode 100644 index 0000000..05df316 --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/other/AddFrame.kt @@ -0,0 +1,15 @@ +package com.ivy.navigation.destinations.other + +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import com.ivy.navigation.DestinationRoute +import com.ivy.navigation.Screen + +object AddFrame : Screen { + override val route = "add-frame" + override val arguments: List = emptyList() + + override fun parse(entry: NavBackStackEntry) {} + + override fun destination(arg: Unit): DestinationRoute = route +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/other/ExchangeRate.kt b/navigation/src/main/java/com/ivy/navigation/destinations/other/ExchangeRate.kt new file mode 100644 index 0000000..25835f9 --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/other/ExchangeRate.kt @@ -0,0 +1,7 @@ +package com.ivy.navigation.destinations.other + +import com.ivy.navigation.NoArgsScreen + +object ExchangeRate : NoArgsScreen() { + override val route = "exchange-rates" +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/settings/Settings.kt b/navigation/src/main/java/com/ivy/navigation/destinations/settings/Settings.kt new file mode 100644 index 0000000..bd95a9f --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/settings/Settings.kt @@ -0,0 +1,15 @@ +package com.ivy.navigation.destinations.settings + +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import com.ivy.navigation.DestinationRoute +import com.ivy.navigation.Screen + +object Settings : Screen { + override val route = "/settings" + override val arguments: List = emptyList() + + override fun parse(entry: NavBackStackEntry) {} + + override fun destination(arg: Unit): DestinationRoute = route +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/transaction/AccountTransactions.kt b/navigation/src/main/java/com/ivy/navigation/destinations/transaction/AccountTransactions.kt new file mode 100644 index 0000000..e81c238 --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/transaction/AccountTransactions.kt @@ -0,0 +1,26 @@ +package com.ivy.navigation.destinations.transaction + +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavType +import androidx.navigation.navArgument +import com.ivy.navigation.DestinationRoute +import com.ivy.navigation.Screen +import com.ivy.navigation.util.stringArg + +object AccountTransactions : Screen { + private const val ARG_ACC_ID = "accId" + + override val route = "account-transactions/{$ARG_ACC_ID}" + + override val arguments: List = listOf( + navArgument(ARG_ACC_ID) { + type = NavType.StringType + nullable = false + } + ) + + override fun destination(arg: String): DestinationRoute = "account-transactions/$arg" + + override fun parse(entry: NavBackStackEntry): String = entry.stringArg(ARG_ACC_ID) +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/transaction/CategoryTransactions.kt b/navigation/src/main/java/com/ivy/navigation/destinations/transaction/CategoryTransactions.kt new file mode 100644 index 0000000..349d27e --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/transaction/CategoryTransactions.kt @@ -0,0 +1,26 @@ +package com.ivy.navigation.destinations.transaction + +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavType +import androidx.navigation.navArgument +import com.ivy.navigation.DestinationRoute +import com.ivy.navigation.Screen +import com.ivy.navigation.util.stringArg + +object CategoryTransactions : Screen { + private const val ARG_CAT_ID = "catId" + + override val route = "category-transactions/{$ARG_CAT_ID}" + + override val arguments: List = listOf( + navArgument(ARG_CAT_ID) { + type = NavType.StringType + nullable = false + } + ) + + override fun destination(arg: String): DestinationRoute = "category-transactions/$arg" + + override fun parse(entry: NavBackStackEntry): String = entry.stringArg(ARG_CAT_ID) +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/transaction/NewTransaction.kt b/navigation/src/main/java/com/ivy/navigation/destinations/transaction/NewTransaction.kt new file mode 100644 index 0000000..b001f79 --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/transaction/NewTransaction.kt @@ -0,0 +1,60 @@ +package com.ivy.navigation.destinations.transaction + +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavType +import androidx.navigation.navArgument +import com.ivy.data.transaction.TransactionType +import com.ivy.navigation.DestinationRoute +import com.ivy.navigation.Screen +import com.ivy.navigation.util.arg +import com.ivy.navigation.util.int +import com.ivy.navigation.util.optionalStringArg + +object NewTransaction : Screen { + data class Arg( + val trnType: TransactionType, + val categoryId: String? = null, + val accountId: String? = null, + ) + + private const val ARG_TRN_TYPE = "trnType" + private const val ARG_CATEGORY_ID = "catId" + private const val ARG_ACCOUNT_ID = "accId" + + override val route: String = "new/transaction?$ARG_TRN_TYPE={$ARG_TRN_TYPE}" + + "&$ARG_CATEGORY_ID={$ARG_CATEGORY_ID}&$ARG_ACCOUNT_ID={$ARG_ACCOUNT_ID}" + + override val arguments = listOf( + navArgument(ARG_TRN_TYPE) { + type = NavType.StringType + nullable = false + }, + navArgument(ARG_CATEGORY_ID) { + type = NavType.StringType + nullable = true + }, + navArgument(ARG_ACCOUNT_ID) { + type = NavType.StringType + nullable = true + } + ) + + override fun destination(arg: Arg): DestinationRoute { + val route = StringBuilder("new/transaction?$ARG_TRN_TYPE=${arg.trnType.code}") + if (arg.categoryId != null) { + route.append("&$ARG_CATEGORY_ID=${arg.categoryId}") + } + if (arg.accountId != null) { + route.append("&$ARG_ACCOUNT_ID=${arg.accountId}") + } + return route.toString() + } + + override fun parse(entry: NavBackStackEntry): Arg = Arg( + trnType = entry.arg(ARG_TRN_TYPE, int()) { + TransactionType.fromCode((it)) ?: TransactionType.Expense + }, + categoryId = entry.optionalStringArg(ARG_CATEGORY_ID), + accountId = entry.optionalStringArg(ARG_ACCOUNT_ID), + ) +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/transaction/NewTransfer.kt b/navigation/src/main/java/com/ivy/navigation/destinations/transaction/NewTransfer.kt new file mode 100644 index 0000000..92fa737 --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/transaction/NewTransfer.kt @@ -0,0 +1,16 @@ +package com.ivy.navigation.destinations.transaction + +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import com.ivy.navigation.DestinationRoute +import com.ivy.navigation.Screen + +object NewTransfer : Screen { + override val route: String = "new/transfer" + + override val arguments: List = emptyList() + + override fun destination(arg: Unit): DestinationRoute = route + + override fun parse(entry: NavBackStackEntry) {} +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/transaction/Transaction.kt b/navigation/src/main/java/com/ivy/navigation/destinations/transaction/Transaction.kt new file mode 100644 index 0000000..7196724 --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/transaction/Transaction.kt @@ -0,0 +1,25 @@ +package com.ivy.navigation.destinations.transaction + +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavType +import androidx.navigation.navArgument +import com.ivy.navigation.DestinationRoute +import com.ivy.navigation.Screen +import com.ivy.navigation.util.stringArg + +object Transaction : Screen { + private const val ARG_TRN_ID = "trnId" + + override val route = "transaction/{$ARG_TRN_ID}" + + override val arguments = listOf( + navArgument(ARG_TRN_ID) { + type = NavType.StringType + nullable = false + } + ) + + override fun destination(arg: String): DestinationRoute = "transaction/$arg" + + override fun parse(entry: NavBackStackEntry): String = entry.stringArg(ARG_TRN_ID) +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/destinations/transaction/Transfer.kt b/navigation/src/main/java/com/ivy/navigation/destinations/transaction/Transfer.kt new file mode 100644 index 0000000..84e7dfd --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/destinations/transaction/Transfer.kt @@ -0,0 +1,25 @@ +package com.ivy.navigation.destinations.transaction + +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavType +import androidx.navigation.navArgument +import com.ivy.navigation.DestinationRoute +import com.ivy.navigation.Screen +import com.ivy.navigation.util.stringArg + +object Transfer : Screen { + private const val ARG_BATCH_ID = "batchId" + + override val route = "transfer/{$ARG_BATCH_ID}" + + override val arguments = listOf( + navArgument(ARG_BATCH_ID) { + type = NavType.StringType + nullable = false + } + ) + + override fun destination(arg: String): DestinationRoute = "transfer/$arg" + + override fun parse(entry: NavBackStackEntry): String = entry.stringArg(ARG_BATCH_ID) +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/graph/DebugGraph.kt b/navigation/src/main/java/com/ivy/navigation/graph/DebugGraph.kt new file mode 100644 index 0000000..5de59df --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/graph/DebugGraph.kt @@ -0,0 +1,25 @@ +package com.ivy.navigation.graph + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.navigation +import com.ivy.navigation.destinations.debug.DebugGraph +import com.ivy.navigation.destinations.debug.screen.Test + +@Immutable +data class DebugScreens( + val test: @Composable () -> Unit +) + +internal fun NavGraphBuilder.debug(screens: DebugScreens) { + navigation( + route = DebugGraph.route, + startDestination = DebugGraph.startDestination + ) { + composable(Test.route) { + screens.test() + } + } +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/graph/OnboardingGraph.kt b/navigation/src/main/java/com/ivy/navigation/graph/OnboardingGraph.kt new file mode 100644 index 0000000..855ac53 --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/graph/OnboardingGraph.kt @@ -0,0 +1,46 @@ +package com.ivy.navigation.graph + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.navigation +import com.ivy.navigation.destinations.onboarding.OnboardingGraph + +@Immutable +data class OnboardingScreens( + val loginOrOffline: @Composable () -> Unit, + val importBackup: @Composable () -> Unit, + val setCurrency: @Composable () -> Unit, + val addAccounts: @Composable () -> Unit, + val addCategories: @Composable () -> Unit, + val debug: @Composable () -> Unit, +) + +internal fun NavGraphBuilder.onboardingGraph( + screens: OnboardingScreens +) { + navigation( + route = OnboardingGraph.route, + startDestination = OnboardingGraph.startDestination, + ) { + composable(OnboardingGraph.debug.route) { + screens.debug() + } + composable(OnboardingGraph.loginOrOffline.route) { + screens.loginOrOffline() + } + composable(OnboardingGraph.importBackup.route) { + screens.importBackup() + } + composable(OnboardingGraph.setCurrency.route) { + screens.setCurrency() + } + composable(OnboardingGraph.addAccounts.route) { + screens.addAccounts() + } + composable(OnboardingGraph.addCategories.route) { + screens.addCategories() + } + } +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/graph/TransactionScreens.kt b/navigation/src/main/java/com/ivy/navigation/graph/TransactionScreens.kt new file mode 100644 index 0000000..fd8509a --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/graph/TransactionScreens.kt @@ -0,0 +1,40 @@ +package com.ivy.navigation.graph + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.ivy.navigation.destinations.transaction.* + +@Immutable +data class TransactionScreens( + val accountTransactions: @Composable (accountId: String) -> Unit, + val categoryTransactions: @Composable (categoryId: String) -> Unit, + val newTransaction: @Composable (NewTransaction.Arg) -> Unit, + val transaction: @Composable (trnId: String) -> Unit, + val newTransfer: @Composable () -> Unit, + val transfer: @Composable (batchId: String) -> Unit, +) + +fun NavGraphBuilder.transactionScreens( + screens: TransactionScreens +) { + composable(AccountTransactions.route) { + screens.accountTransactions(AccountTransactions.parse(it)) + } + composable(CategoryTransactions.route) { + screens.categoryTransactions(CategoryTransactions.parse(it)) + } + composable(NewTransaction.route) { + screens.newTransaction(NewTransaction.parse(it)) + } + composable(Transaction.route) { + screens.transaction(Transaction.parse(it)) + } + composable(NewTransfer.route) { + screens.newTransfer() + } + composable(Transfer.route) { + screens.transfer(Transfer.parse(it)) + } +} \ No newline at end of file diff --git a/navigation/src/main/java/com/ivy/navigation/util/NavBackStackEntryExt.kt b/navigation/src/main/java/com/ivy/navigation/util/NavBackStackEntryExt.kt new file mode 100644 index 0000000..946f3d1 --- /dev/null +++ b/navigation/src/main/java/com/ivy/navigation/util/NavBackStackEntryExt.kt @@ -0,0 +1,24 @@ +package com.ivy.navigation.util + +import androidx.navigation.NavBackStackEntry + +fun NavBackStackEntry.stringArg(key: String): String = arg(key = key, type = string()) { it } +fun NavBackStackEntry.optionalStringArg(key: String): String? = + optionalArg(key = key, type = string()) { it } + +fun NavBackStackEntry.arg( + key: String, + type: NavBackStackEntry.(String) -> ArgPrimitive?, + transform: (ArgPrimitive) -> Arg +): Arg = + optionalArg(key = key, type = type, transform = transform) ?: error("missing '$key' argument") + +fun NavBackStackEntry.optionalArg( + key: String, + type: NavBackStackEntry.(String) -> ArgPrimitive?, + transform: (ArgPrimitive) -> Arg +): Arg? = type(key)?.let(transform) + +fun string(): NavBackStackEntry.(String) -> String? = { key -> arguments?.getString(key) } +fun int(): NavBackStackEntry.(String) -> Int? = { key -> arguments?.getString(key)?.toIntOrNull() } +fun bool(): NavBackStackEntry.(String) -> Boolean? = { key -> arguments?.getBoolean(key) } \ No newline at end of file diff --git a/network/.gitignore b/network/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/network/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/network/README.md b/network/README.md new file mode 100644 index 0000000..001662b --- /dev/null +++ b/network/README.md @@ -0,0 +1,3 @@ +# Network + +Provides a configured Ktor client via the `ktorClient()` function. \ No newline at end of file diff --git a/network/build.gradle.kts b/network/build.gradle.kts new file mode 100644 index 0000000..0144f8c --- /dev/null +++ b/network/build.gradle.kts @@ -0,0 +1,15 @@ +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Ktor + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + Ktor(api = true) +} \ No newline at end of file diff --git a/network/src/main/AndroidManifest.xml b/network/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6fe0081 --- /dev/null +++ b/network/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/network/src/main/java/com/ivy/network/HttpClient.kt b/network/src/main/java/com/ivy/network/HttpClient.kt new file mode 100644 index 0000000..80ad975 --- /dev/null +++ b/network/src/main/java/com/ivy/network/HttpClient.kt @@ -0,0 +1,14 @@ +package com.ivy.network + +import io.ktor.client.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.logging.* +import io.ktor.serialization.gson.* + +fun ktorClient(): HttpClient = HttpClient { + install(Logging) + + install(ContentNegotiation) { + gson() + } +} \ No newline at end of file diff --git a/onboarding/.gitignore b/onboarding/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/onboarding/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/onboarding/README.md b/onboarding/README.md new file mode 100644 index 0000000..80f8e6d --- /dev/null +++ b/onboarding/README.md @@ -0,0 +1,19 @@ +# Onboarding + +Ivy Wallet's onboarding flow. + +## 🚧 Module under construction... + +If it hardly works, it's filled with bad code and anti-patterns anyway... + +### To see how a proper should look like refer to: + +- **[:core](../core)**: responsible for Ivy Wallet's domain +- **[:home](../home/)**: Ivy wallet's home screen. + +Apologies for the inconvenience! I'm a solo dev + the help of our awesome Ivy contributors. If you +want to support us: + +1. Star our repo. + [![GitHub Repo stars](https://img.shields.io/github/stars/Ivy-Apps/ivy-wallet?style=social)](https://github.com/Ivy-Apps/ivy-wallet/stargazers) +2. Have a look at our [Contributors Guide](../CONTRIBUTING.md). \ No newline at end of file diff --git a/onboarding/build.gradle.kts b/onboarding/build.gradle.kts new file mode 100644 index 0000000..2e505c0 --- /dev/null +++ b/onboarding/build.gradle.kts @@ -0,0 +1,18 @@ +import com.ivy.buildsrc.Hilt + +apply() + +plugins { + `android-library` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":design-system")) + implementation(project(":core:ui")) + implementation(project(":core:domain")) + implementation(project(":core:persistence")) + implementation(project(":navigation")) + +} \ No newline at end of file diff --git a/onboarding/src/main/AndroidManifest.xml b/onboarding/src/main/AndroidManifest.xml new file mode 100644 index 0000000..81b8a4b --- /dev/null +++ b/onboarding/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/onboarding/src/main/java/com/ivy/onboarding/action/OnboardingFinishedAct.kt b/onboarding/src/main/java/com/ivy/onboarding/action/OnboardingFinishedAct.kt new file mode 100644 index 0000000..7e17c4e --- /dev/null +++ b/onboarding/src/main/java/com/ivy/onboarding/action/OnboardingFinishedAct.kt @@ -0,0 +1,15 @@ +package com.ivy.onboarding.action + +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.datastore.IvyDataStore +import com.ivy.onboarding.datastore.OnboardingKeys +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +class OnboardingFinishedAct @Inject constructor( + private val dataStore: IvyDataStore, + private val onboardingKeys: OnboardingKeys, +) : Action() { + override suspend fun action(input: Unit): Boolean = + dataStore.get(onboardingKeys.onboardingFinished).first() ?: false +} \ No newline at end of file diff --git a/onboarding/src/main/java/com/ivy/onboarding/action/WriteOnboardingFinishedAct.kt b/onboarding/src/main/java/com/ivy/onboarding/action/WriteOnboardingFinishedAct.kt new file mode 100644 index 0000000..12457f7 --- /dev/null +++ b/onboarding/src/main/java/com/ivy/onboarding/action/WriteOnboardingFinishedAct.kt @@ -0,0 +1,17 @@ +package com.ivy.onboarding.action + +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.datastore.IvyDataStore +import com.ivy.onboarding.datastore.OnboardingKeys +import javax.inject.Inject + +class WriteOnboardingFinishedAct @Inject constructor( + private val dataStore: IvyDataStore, + private val onboardingKeys: OnboardingKeys, +) : Action() { + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override suspend fun action(finished: Boolean) { + dataStore.put(onboardingKeys.onboardingFinished, finished) + } +} \ No newline at end of file diff --git a/onboarding/src/main/java/com/ivy/onboarding/datastore/OnboardingKeys.kt b/onboarding/src/main/java/com/ivy/onboarding/datastore/OnboardingKeys.kt new file mode 100644 index 0000000..bfca743 --- /dev/null +++ b/onboarding/src/main/java/com/ivy/onboarding/datastore/OnboardingKeys.kt @@ -0,0 +1,8 @@ +package com.ivy.onboarding.datastore + +import androidx.datastore.preferences.core.booleanPreferencesKey +import javax.inject.Inject + +class OnboardingKeys @Inject constructor() { + val onboardingFinished by lazy { booleanPreferencesKey("onboarding_finished") } +} \ No newline at end of file diff --git a/onboarding/src/main/java/com/ivy/onboarding/old/OnboardingScreen.kt b/onboarding/src/main/java/com/ivy/onboarding/old/OnboardingScreen.kt new file mode 100644 index 0000000..e4610fc --- /dev/null +++ b/onboarding/src/main/java/com/ivy/onboarding/old/OnboardingScreen.kt @@ -0,0 +1,173 @@ +//package com.ivy.onboarding +// +//import androidx.compose.foundation.ExperimentalFoundationApi +//import androidx.compose.foundation.isSystemInDarkTheme +//import androidx.compose.foundation.layout.BoxWithConstraintsScope +//import androidx.compose.runtime.Composable +//import androidx.compose.runtime.getValue +//import androidx.compose.runtime.livedata.observeAsState +//import androidx.compose.ui.tooling.preview.Preview +//import androidx.hilt.navigation.compose.hiltViewModel +//import com.ivy.base.AccountBalance +//import com.ivy.data.AccountOld +//import com.ivy.data.CategoryOld +//import com.ivy.data.IvyCurrency +//import com.ivy.design.util.IvyPreview +// +//import com.ivy.onboarding.steps.* +//import com.ivy.onboarding.viewmodel.OnboardingViewModel +//import com.ivy.wallet.domain.deprecated.logic.model.CreateAccountData +//import com.ivy.wallet.domain.deprecated.logic.model.CreateCategoryData +//import com.ivy.wallet.utils.OpResult +// +//@ExperimentalFoundationApi +//@Composable +//fun BoxWithConstraintsScope.OnboardingScreen() { +// val viewModel: OnboardingViewModel = hiltViewModel() +// +// val state by viewModel.state.observeAsState(OnboardingState.SPLASH) +// val currency by viewModel.currency.observeAsState(IvyCurrency.getDefault()) +// val opGoogleSign by viewModel.opGoogleSignIn.observeAsState() +// +// val accountSuggestions by viewModel.accountSuggestions.observeAsState(emptyList()) +// val accounts by viewModel.accounts.observeAsState(listOf()) +// +// val categorySuggestions by viewModel.categorySuggestions.observeAsState(emptyList()) +// val categories by viewModel.categories.observeAsState(emptyList()) +// +// val isSystemDarkTheme = isSystemInDarkTheme() +// +// UI( +// onboardingState = state, +// currency = currency, +// opGoogleSignIn = opGoogleSign, +// +// accountSuggestions = accountSuggestions, +// accounts = accounts, +// +// categorySuggestions = categorySuggestions, +// categories = categories, +// +// onLoginWithGoogle = viewModel::loginWithGoogle, +// onSkip = viewModel::loginOfflineAccount, +// +// onStartImport = viewModel::startImport, +// onStartFresh = viewModel::startFresh, +// +// onSetCurrency = viewModel::setBaseCurrency, +// +// onCreateAccount = viewModel::createAccount, +// onEditAccount = viewModel::editAccount, +// onAddAccountsDone = viewModel::onAddAccountsDone, +// onAddAccountsSkip = viewModel::onAddAccountsSkip, +// +// onCreateCategory = viewModel::createCategory, +// onEditCategory = viewModel::editCategory, +// onAddCategoryDone = viewModel::onAddCategoriesDone, +// onAddCategorySkip = viewModel::onAddCategoriesSkip +// ) +//} +// +//@ExperimentalFoundationApi +//@Composable +//private fun BoxWithConstraintsScope.UI( +// onboardingState: OnboardingState, +// currency: IvyCurrency, +// opGoogleSignIn: OpResult?, +// +// accountSuggestions: List, +// accounts: List, +// +// categorySuggestions: List, +// categories: List, +// +// onLoginWithGoogle: () -> Unit = {}, +// onSkip: () -> Unit = {}, +// +// onStartImport: () -> Unit = {}, +// onStartFresh: () -> Unit = {}, +// +// onSetCurrency: (IvyCurrency) -> Unit = {}, +// +// onCreateAccount: (CreateAccountData) -> Unit = { }, +// onEditAccount: (AccountOld, Double) -> Unit = { _, _ -> }, +// onAddAccountsDone: () -> Unit = {}, +// onAddAccountsSkip: () -> Unit = {}, +// +// onCreateCategory: (CreateCategoryData) -> Unit = {}, +// onEditCategory: (CategoryOld) -> Unit = {}, +// onAddCategoryDone: () -> Unit = {}, +// onAddCategorySkip: () -> Unit = {}, +//) { +// when (onboardingState) { +// OnboardingState.SPLASH, OnboardingState.LOGIN -> { +// OnboardingSplashLogin( +// onboardingState = onboardingState, +// opGoogleSignIn = opGoogleSignIn, +// +// onLoginWithGoogle = onLoginWithGoogle, +// onSkip = onSkip +// ) +// } +// OnboardingState.CHOOSE_PATH -> { +// OnboardingType( +// onStartImport = onStartImport, +// onStartFresh = onStartFresh +// ) +// } +// OnboardingState.CURRENCY -> { +// OnboardingSetCurrency( +// preselectedCurrency = currency, +// onSetCurrency = onSetCurrency +// ) +// } +// OnboardingState.ACCOUNTS -> { +// OnboardingAccounts( +// baseCurrency = currency.code, +// suggestions = accountSuggestions, +// accounts = accounts, +// +// onCreateAccount = onCreateAccount, +// onEditAccount = onEditAccount, +// +// onDone = onAddAccountsDone, +// onSkip = onAddAccountsSkip +// ) +// } +// OnboardingState.CATEGORIES -> { +// OnboardingCategories( +// suggestions = categorySuggestions, +// categories = categories, +// +// onCreateCategory = onCreateCategory, +// onEditCategory = onEditCategory, +// +// onDone = onAddCategoryDone, +// onSkip = onAddCategorySkip +// ) +// } +// } +//} +// +//@ExperimentalFoundationApi +//@Preview +//@Composable +//private fun PreviewOnboarding() { +// IvyPreview { +// UI( +// accountSuggestions = listOf(), +// accounts = listOf(), +// +// categorySuggestions = listOf(), +// categories = listOf(), +// +// onboardingState = OnboardingState.SPLASH, +// currency = IvyCurrency.getDefault(), +// opGoogleSignIn = null, +// +// onLoginWithGoogle = {}, +// onSkip = {}, +// onSetCurrency = {}, +// ) +// } +//} \ No newline at end of file diff --git a/onboarding/src/main/java/com/ivy/onboarding/old/OnboardingState.kt b/onboarding/src/main/java/com/ivy/onboarding/old/OnboardingState.kt new file mode 100644 index 0000000..5608f24 --- /dev/null +++ b/onboarding/src/main/java/com/ivy/onboarding/old/OnboardingState.kt @@ -0,0 +1,10 @@ +//package com.ivy.onboarding +// +//enum class OnboardingState { +// SPLASH, +// LOGIN, +// CHOOSE_PATH, +// CURRENCY, +// ACCOUNTS, +// CATEGORIES +//} \ No newline at end of file diff --git a/onboarding/src/main/java/com/ivy/onboarding/old/steps/OnboardingAccounts.kt b/onboarding/src/main/java/com/ivy/onboarding/old/steps/OnboardingAccounts.kt new file mode 100644 index 0000000..9a4ab22 --- /dev/null +++ b/onboarding/src/main/java/com/ivy/onboarding/old/steps/OnboardingAccounts.kt @@ -0,0 +1,428 @@ +//package com.ivy.onboarding.steps +// +//import androidx.compose.foundation.* +//import androidx.compose.foundation.layout.* +//import androidx.compose.foundation.lazy.LazyColumn +//import androidx.compose.foundation.shape.CircleShape +//import androidx.compose.material.Text +//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.Color +//import androidx.compose.ui.graphics.toArgb +//import androidx.compose.ui.res.painterResource +//import androidx.compose.ui.res.stringResource +//import androidx.compose.ui.text.font.FontWeight +//import androidx.compose.ui.tooling.preview.Preview +//import androidx.compose.ui.unit.dp +//import com.ivy.base.AccountBalance +//import com.ivy.resources.R +//import com.ivy.data.AccountOld +//import com.ivy.design.l0_system.UI +//import com.ivy.design.l0_system.style +//import com.ivy.design.util.IvyPreview +// +//import com.ivy.old.OnboardingProgressSlider +//import com.ivy.old.OnboardingToolbar +//import com.ivy.old.Suggestions +//import com.ivy.wallet.domain.deprecated.logic.model.CreateAccountData +//import com.ivy.wallet.ui.theme.* +//import com.ivy.wallet.ui.theme.components.GradientCutBottom +//import com.ivy.wallet.ui.theme.components.ItemIconMDefaultIcon +//import com.ivy.wallet.ui.theme.components.IvyIcon +//import com.ivy.wallet.ui.theme.components.OnboardingButton +//import com.ivy.wallet.ui.theme.modal.edit.AccountModal +//import com.ivy.wallet.ui.theme.modal.edit.AccountModalData +//import com.ivy.wallet.ui.theme.wallet.AmountCurrencyB1Row +//import com.ivy.wallet.utils.toLowerCaseLocal +// +//@ExperimentalFoundationApi +//@Composable +//fun BoxWithConstraintsScope.OnboardingAccounts( +// baseCurrency: String, +// +// suggestions: List, +// accounts: List, +// +// onCreateAccount: (CreateAccountData) -> Unit = { }, +// onEditAccount: (AccountOld, Double) -> Unit = { _, _ -> }, +// +// onSkip: () -> Unit = {}, +// onDone: () -> Unit = {} +//) { +// var accountModalData: AccountModalData? by remember { mutableStateOf(null) } +// +// LazyColumn( +// modifier = Modifier +// .fillMaxSize() +// .statusBarsPadding() +// .navigationBarsPadding() +// ) { +// stickyHeader { +// +// OnboardingToolbar( +// hasSkip = accounts.isEmpty(), onBack = { nav.onBackPressed() }, onSkip = onSkip +// ) +// } +// +// item { +// Column { +// Spacer(Modifier.height(8.dp)) +// +// Text( +// modifier = Modifier.padding(horizontal = 32.dp), +// text = stringResource(R.string.add_accounts), +// style = UI.typo.h2.style( +// fontWeight = FontWeight.Black +// ) +// ) +// +//// PremiumInfo( +//// itemLabelPlural = "accounts", +//// itemsCount = accounts.size, +//// freeItemsCount = Constants.FREE_ACCOUNTS +//// ) +// +// if (accounts.isEmpty()) { +// Spacer(Modifier.height(16.dp)) +// +// Image( +// modifier = Modifier.align(Alignment.CenterHorizontally), +// painter = painterResource(id = R.drawable.onboarding_illustration_accounts), +// contentDescription = "account illustration" +// ) +// +// OnboardingProgressSlider( +// modifier = Modifier.align(Alignment.CenterHorizontally), +// selectedStep = 2, +// stepsCount = 4, +// selectedColor = Orange +// ) +// +// Spacer(Modifier.height(48.dp)) +// } else { +// Spacer(Modifier.height(24.dp)) +// } +// +// Accounts(baseCurrency = baseCurrency, accounts = accounts, onClick = { +// accountModalData = AccountModalData( +// account = it.account, +// baseCurrency = baseCurrency, +// balance = it.balance, +// autoFocusKeyboard = false +// ) +// }) +// +// if (accounts.isNotEmpty()) { +// Spacer(Modifier.height(20.dp)) +// } +// +// Text( +// modifier = Modifier.padding(horizontal = 32.dp), +// text = stringResource(R.string.suggestion), +// style = UI.typo.b1.style( +// fontWeight = FontWeight.ExtraBold +// ) +// ) +// +// Spacer(Modifier.height(16.dp)) +// +// Suggestions(suggestions = suggestions.filter { suggestion -> +// accounts.map { it.account.name.toLowerCaseLocal() } +// .contains(suggestion.name.toLowerCaseLocal()).not() +// }, onAddSuggestion = { +// onCreateAccount(it as CreateAccountData) +// }, onAddNew = { +// accountModalData = AccountModalData( +// account = null, baseCurrency = baseCurrency, balance = 0.0 +// ) +// }) +// +// Spacer(Modifier.height(96.dp)) +// } +// } +// } +// +// GradientCutBottom( +// height = 96.dp +// ) +// +// if (accounts.isNotEmpty()) { +// OnboardingButton( +// Modifier +// .fillMaxWidth() +// .padding(horizontal = 16.dp) +// .align(Alignment.BottomCenter) +// .navigationBarsPadding() +// .padding(bottom = 20.dp), +// +// text = stringResource(R.string.next), +// textColor = White, +// backgroundGradient = GradientIvy, +// hasNext = true, +// enabled = true +// ) { +// onDone() +// } +// } +// +// AccountModal(modal = accountModalData, +// onCreateAccount = onCreateAccount, +// onEditAccount = onEditAccount, +// dismiss = { +// accountModalData = null +// }) +//} +// +//@Composable +//fun PremiumInfo( +// itemLabelPlural: String, itemsCount: Int, freeItemsCount: Int +//) { +// val freeItemsLeft = freeItemsCount - itemsCount +// +// if (freeItemsLeft > 0) { +// Spacer(Modifier.height(8.dp)) +// +// Text( +// modifier = Modifier.padding(horizontal = 32.dp), +// text = if (itemsCount == 0) "Up to $freeItemsCount free $itemLabelPlural" else "$freeItemsLeft $itemLabelPlural left", +// style = UI.typoSecond.b2.style( +// fontWeight = FontWeight.Bold, color = if (freeItemsLeft > 2) Green else Orange +// ) +// ) +// } else if (false) { +// Spacer(Modifier.height(24.dp)) +// +// BuyPremiumRow( +// itemLabelPlural = itemLabelPlural +// ) +// } +//} +// +//@Composable +//fun BuyPremiumRow( +// itemLabelPlural: String, +//) { +// +// Row( +// modifier = Modifier +// .fillMaxWidth() +// .padding(horizontal = 16.dp) +// .clip(UI.shapes.squared) +// .border(2.dp, UI.colors.medium, UI.shapes.squared) +// .clickable { +//// nav.navigateTo( +//// Paywall( +//// paywallReason = PaywallReason.ACCOUNTS +//// ) +//// ) +// }, verticalAlignment = Alignment.CenterVertically +// ) { +// Spacer(Modifier.width(16.dp)) +// +// IvyIcon( +// icon = R.drawable.ic_premium_small, tint = Red +// ) +// +// Text( +// modifier = Modifier +// .padding(vertical = 12.dp) +// .padding(start = 12.dp, end = 32.dp), +// text = "Buy premium for unlimited number of $itemLabelPlural", +// style = UI.typo.b2.style( +// fontWeight = FontWeight.Bold, color = Red +// ) +// ) +// } +//} +// +//@Composable +//private fun Accounts( +// baseCurrency: String, accounts: List, onClick: (AccountBalance) -> Unit +//) { +// for (account in accounts) { +// AccountCard( +// baseCurrency = baseCurrency, accountBalance = account +// ) { +// onClick(account) +// } +// +// Spacer(Modifier.height(12.dp)) +// } +//} +// +//@Composable +//private fun AccountCard( +// baseCurrency: String, accountBalance: AccountBalance, onClick: () -> Unit +//) { +// val account = accountBalance.account +// val accountColor = account.color.toComposeColor() +// val dynamicContrast = accountColor.dynamicContrast() +// +// +// Row( +// modifier = Modifier +// .fillMaxWidth() +// .padding(horizontal = 16.dp) +// .clip(UI.shapes.rounded) +// .background(accountColor, UI.shapes.rounded) +// .clickable { +// onClick() +// }, verticalAlignment = Alignment.CenterVertically +// ) { +// Spacer(Modifier.width(24.dp)) +// +// ItemIconMDefaultIcon( +// modifier = Modifier +// .padding(vertical = 16.dp) +// .background(dynamicContrast, CircleShape), +// iconName = account.icon, +// defaultIcon = R.drawable.ic_custom_account_m, +// tint = accountColor +// ) +// +// Spacer(Modifier.width(20.dp)) +// +// Column { +// Text( +// text = account.name, style = UI.typo.b1.style( +// fontWeight = FontWeight.ExtraBold, color = dynamicContrast +// ) +// ) +// +// AmountCurrencyB1Row( +// amount = accountBalance.balance, +// currency = account.currency ?: baseCurrency, +// amountFontWeight = FontWeight.ExtraBold, +// textColor = findContrastTextColor(accountColor) +// ) +// } +// +// Spacer(Modifier.width(24.dp)) +// } +//} +// +//@ExperimentalFoundationApi +//@Preview +//@Composable +//private fun Preview_Empty() { +// IvyPreview { +// val baseCurrency = "BGN" +// OnboardingAccounts( +// baseCurrency = baseCurrency, suggestions = listOf( +// CreateAccountData( +// name = "Cash", +// currency = baseCurrency, +// color = Green, +// icon = "cash", +// balance = 0.0 +// ), +// CreateAccountData( +// name = "Bank", +// currency = baseCurrency, +// color = Ivy, +// icon = "bank", +// balance = 0.0 +// ), +// CreateAccountData( +// name = "Revolut", +// currency = baseCurrency, +// color = Color(0xFF4DCAFF), +// icon = "revolut", +// balance = 0.0 +// ), +// ), accounts = listOf() +// ) +// } +//} +// +//@ExperimentalFoundationApi +//@Preview +//@Composable +//private fun Preview_Accounts() { +// IvyPreview { +// val baseCurrency = "BGN" +// OnboardingAccounts( +// baseCurrency = baseCurrency, suggestions = listOf( +// CreateAccountData( +// name = "Cash", +// currency = baseCurrency, +// color = Green, +// icon = "cash", +// balance = 0.0 +// ), +// CreateAccountData( +// name = "Bank", +// currency = baseCurrency, +// color = Ivy, +// icon = "bank", +// balance = 0.0 +// ), +// CreateAccountData( +// name = "Revolut", +// currency = baseCurrency, +// color = Color(0xFF4DCAFF), +// icon = "revolut", +// balance = 0.0 +// ), +// ), accounts = listOf( +// AccountBalance( +// account = AccountOld( +// name = "Cash", color = Green.toArgb(), icon = "cash" +// ), balance = 0.0 +// ) +// ) +// ) +// } +//} +// +//@ExperimentalFoundationApi +//@Preview +//@Composable +//private fun Preview_Premium() { +// IvyPreview { +// val baseCurrency = "BGN" +// OnboardingAccounts( +// baseCurrency = baseCurrency, suggestions = listOf( +// CreateAccountData( +// name = "Cash", +// currency = baseCurrency, +// color = Green, +// icon = "cash", +// balance = 0.0 +// ), +// CreateAccountData( +// name = "Bank", +// currency = baseCurrency, +// color = Ivy, +// icon = "bank", +// balance = 0.0 +// ), +// CreateAccountData( +// name = "Revolut", +// currency = baseCurrency, +// color = Color(0xFF4DCAFF), +// icon = "revolut", +// balance = 0.0 +// ), +// ), accounts = listOf( +// AccountBalance( +// account = AccountOld( +// name = "Cash", color = Green.toArgb(), icon = "cash" +// ), balance = 0.0 +// ), +// AccountBalance( +// account = AccountOld( +// name = "Revolut", color = IvyDark.toArgb(), icon = "cash" +// ), balance = 0.0 +// ), +// AccountBalance( +// account = AccountOld( +// name = "Revolut", color = Color(0xFF4DCAFF).toArgb(), icon = "revolut" +// ), balance = 0.0 +// ), +// ) +// ) +// } +//} \ No newline at end of file diff --git a/onboarding/src/main/java/com/ivy/onboarding/old/steps/OnboardingCategories.kt b/onboarding/src/main/java/com/ivy/onboarding/old/steps/OnboardingCategories.kt new file mode 100644 index 0000000..6e2a1e9 --- /dev/null +++ b/onboarding/src/main/java/com/ivy/onboarding/old/steps/OnboardingCategories.kt @@ -0,0 +1,453 @@ +//package com.ivy.onboarding.steps +// +//import androidx.compose.foundation.ExperimentalFoundationApi +//import androidx.compose.foundation.Image +//import androidx.compose.foundation.background +//import androidx.compose.foundation.clickable +//import androidx.compose.foundation.layout.* +//import androidx.compose.foundation.lazy.LazyColumn +//import androidx.compose.foundation.shape.CircleShape +//import androidx.compose.material.Text +//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.Color +//import androidx.compose.ui.graphics.toArgb +//import androidx.compose.ui.res.painterResource +//import androidx.compose.ui.res.stringResource +//import androidx.compose.ui.text.font.FontWeight +//import androidx.compose.ui.tooling.preview.Preview +//import androidx.compose.ui.unit.dp +//import com.ivy.resources.R +//import com.ivy.data.CategoryOld +//import com.ivy.design.l0_system.UI +//import com.ivy.design.l0_system.style +//import com.ivy.design.util.IvyPreview +// +//import com.ivy.old.OnboardingProgressSlider +//import com.ivy.old.OnboardingToolbar +//import com.ivy.old.Suggestions +//import com.ivy.wallet.domain.deprecated.logic.model.CreateCategoryData +//import com.ivy.wallet.ui.theme.* +//import com.ivy.wallet.ui.theme.components.GradientCutBottom +//import com.ivy.wallet.ui.theme.components.ItemIconSDefaultIcon +//import com.ivy.wallet.ui.theme.components.OnboardingButton +//import com.ivy.wallet.ui.theme.modal.edit.CategoryModal +//import com.ivy.wallet.ui.theme.modal.edit.CategoryModalData +//import com.ivy.wallet.utils.toLowerCaseLocal +// +//@ExperimentalFoundationApi +//@Composable +//fun BoxWithConstraintsScope.OnboardingCategories( +// suggestions: List, +// categories: List, +// +// onCreateCategory: (CreateCategoryData) -> Unit = { }, +// onEditCategory: (CategoryOld) -> Unit = { _ -> }, +// +// onSkip: () -> Unit = {}, +// onDone: () -> Unit = {} +//) { +// var categoryModalData: CategoryModalData? by remember { mutableStateOf(null) } +// +// +// LazyColumn( +// modifier = Modifier +// .fillMaxSize() +// .statusBarsPadding() +// .navigationBarsPadding() +// ) { +// stickyHeader { +// OnboardingToolbar( +// hasSkip = categories.isEmpty(), +// onBack = { nav.onBackPressed() }, +// onSkip = onSkip +// ) +// } +// +// item { +// Column { +// Spacer(Modifier.height(8.dp)) +// +// Text( +// modifier = Modifier.padding(horizontal = 32.dp), +// text = stringResource(R.string.add_categories), +// style = UI.typo.h2.style( +// fontWeight = FontWeight.Black +// ) +// ) +// +//// PremiumInfo( +//// itemLabelPlural = "categories", +//// itemsCount = categories.size, +//// freeItemsCount = Constants.FREE_CATEGORIES +//// ) +// +// if (categories.isEmpty()) { +// Spacer(Modifier.height(16.dp)) +// +// Image( +// modifier = Modifier.align(Alignment.CenterHorizontally), +// painter = painterResource(id = R.drawable.onboarding_illustration_categories), +// contentDescription = "categories illustration" +// ) +// +// OnboardingProgressSlider( +// modifier = Modifier.align(Alignment.CenterHorizontally), +// selectedStep = 3, +// stepsCount = 4, +// selectedColor = IvyDark +// ) +// +// Spacer(Modifier.height(48.dp)) +// } else { +// Spacer(Modifier.height(24.dp)) +// } +// +// Categories( +// categories = categories, +// onClick = { +// categoryModalData = CategoryModalData( +// category = it +// ) +// } +// ) +// +// if (categories.isNotEmpty()) { +// Spacer(Modifier.height(20.dp)) +// } +// +// Text( +// modifier = Modifier.padding(horizontal = 32.dp), +// text = stringResource(R.string.suggestions), +// style = UI.typo.b1.style( +// fontWeight = FontWeight.ExtraBold +// ) +// ) +// +// Spacer(Modifier.height(16.dp)) +// +// Suggestions( +// suggestions = suggestions.filter { suggestion -> +// categories.map { it.name.toLowerCaseLocal() } +// .contains(suggestion.name.toLowerCaseLocal()).not() +// }, +// onAddSuggestion = { +// onCreateCategory(it as CreateCategoryData) +// }, +// onAddNew = { +// categoryModalData = CategoryModalData( +// category = null +// ) +// } +// ) +// +// Spacer(Modifier.height(96.dp)) +// } +// } +// } +// +// GradientCutBottom( +// height = 96.dp +// ) +// +// if (categories.isNotEmpty()) { +// OnboardingButton( +// Modifier +// .fillMaxWidth() +// .padding(horizontal = 16.dp) +// .align(Alignment.BottomCenter) +// .navigationBarsPadding() +// .padding(bottom = 20.dp), +// +// text = stringResource(R.string.finish), +// textColor = White, +// backgroundGradient = GradientIvy, +// hasNext = false, +// enabled = true +// ) { +// onDone() +// } +// } +// +// CategoryModal( +// modal = categoryModalData, +// onCreateCategory = onCreateCategory, +// onEditCategory = onEditCategory, +// dismiss = { +// categoryModalData = null +// } +// ) +//} +// +// +//@Composable +//private fun Categories( +// categories: List, +// onClick: (CategoryOld) -> Unit +//) { +// for (category in categories) { +// CategoryCard( +// category = category +// ) { +// onClick(category) +// } +// +// Spacer(Modifier.height(8.dp)) +// } +//} +// +//@Composable +//private fun CategoryCard( +// category: CategoryOld, +// onClick: () -> Unit +//) { +// val categoryColor = category.color.toComposeColor() +// +// Row( +// modifier = Modifier +// .fillMaxWidth() +// .padding(horizontal = 16.dp) +// .clip(UI.shapes.rounded) +// .background(UI.colors.medium, UI.shapes.rounded) +// .clickable { +// onClick() +// }, +// verticalAlignment = Alignment.CenterVertically +// ) { +// Spacer(Modifier.width(20.dp)) +// +// ItemIconSDefaultIcon( +// modifier = Modifier +// .background(categoryColor, CircleShape), +// iconName = category.icon, +// defaultIcon = R.drawable.ic_custom_category_s, +// tint = findContrastTextColor(categoryColor) +// ) +// +// Spacer(Modifier.width(16.dp)) +// +// Text( +// modifier = Modifier +// .padding(start = 16.dp, end = 24.dp) +// .padding(vertical = 24.dp), +// text = category.name, +// style = UI.typo.b2.style( +// fontWeight = FontWeight.Bold +// ) +// ) +// +// Spacer(Modifier.width(24.dp)) +// } +//} +// +//@ExperimentalFoundationApi +//@Preview +//@Composable +//private fun Preview_Empty() { +// IvyPreview { +// OnboardingCategories( +// suggestions = listOf( +// CreateCategoryData( +// name = "Food & Drinks", +// color = Green, +// icon = "fooddrink" +// ), +// +// CreateCategoryData( +// name = "Bills & Fees", +// color = Red, +// icon = "bills" +// ), +// +// CreateCategoryData( +// name = "Transport", +// color = OrangeLight, +// icon = "transport" +// ), +// +// CreateCategoryData( +// name = "Groceries", +// color = Color(0xFF75ff4d), +// icon = "groceries" +// ), +// +// CreateCategoryData( +// name = "Entertainment", +// color = Orange, +// icon = "game" +// ), +// +// CreateCategoryData( +// name = "Shopping", +// color = Ivy, +// icon = "shopping" +// ), +// +// CreateCategoryData( +// name = "Gifts", +// color = RedLight, +// icon = "gift" +// ), +// +// CreateCategoryData( +// name = "Health", +// color = Color(0xFF4dfff3), +// icon = "health" +// ), +// +// CreateCategoryData( +// name = "Investments", +// color = Color(0xFF1e5166), +// icon = "leaf" +// ), +// ), +// categories = listOf() +// ) +// } +//} +// +//@ExperimentalFoundationApi +//@Preview +//@Composable +//private fun Preview_Categories() { +// IvyPreview { +// OnboardingCategories( +// suggestions = listOf( +// CreateCategoryData( +// name = "Food & Drinks", +// color = Green, +// icon = "fooddrink" +// ), +// +// CreateCategoryData( +// name = "Bills & Fees", +// color = Red, +// icon = "bills" +// ), +// +// CreateCategoryData( +// name = "Transport", +// color = OrangeLight, +// icon = "transport" +// ), +// +// CreateCategoryData( +// name = "Groceries", +// color = Color(0xFF75ff4d), +// icon = "groceries" +// ), +// +// CreateCategoryData( +// name = "Entertainment", +// color = Orange, +// icon = "game" +// ), +// +// CreateCategoryData( +// name = "Shopping", +// color = Ivy, +// icon = "shopping" +// ), +// +// CreateCategoryData( +// name = "Gifts", +// color = RedLight, +// icon = "gift" +// ), +// +// CreateCategoryData( +// name = "Health", +// color = Color(0xFF4dfff3), +// icon = "health" +// ), +// +// CreateCategoryData( +// name = "Investments", +// color = Color(0xFF1e5166), +// icon = "leaf" +// ), +// ), +// categories = listOf( +// CategoryOld( +// name = "Food & Drinks", +// color = Orange.toArgb(), +// icon = "fooddrinks" +// ) +// ) +// ) +// } +//} +// +//@ExperimentalFoundationApi +//@Preview +//@Composable +//private fun Preview_Premium() { +// IvyPreview { +// OnboardingCategories( +// suggestions = listOf( +// CreateCategoryData( +// name = "Food & Drinks", +// color = Green, +// icon = "fooddrink" +// ), +// +// CreateCategoryData( +// name = "Bills & Fees", +// color = Red, +// icon = "bills" +// ), +// +// CreateCategoryData( +// name = "Transport", +// color = OrangeLight, +// icon = "transport" +// ), +// +// CreateCategoryData( +// name = "Groceries", +// color = Color(0xFF75ff4d), +// icon = "groceries" +// ), +// +// CreateCategoryData( +// name = "Entertainment", +// color = Orange, +// icon = "game" +// ), +// +// CreateCategoryData( +// name = "Shopping", +// color = Ivy, +// icon = "shopping" +// ), +// +// CreateCategoryData( +// name = "Gifts", +// color = RedLight, +// icon = "gift" +// ), +// +// CreateCategoryData( +// name = "Health", +// color = Color(0xFF4dfff3), +// icon = "health" +// ), +// +// CreateCategoryData( +// name = "Investments", +// color = Color(0xFF1e5166), +// icon = "leaf" +// ), +// ), +// categories = List(12) { +// CategoryOld( +// name = "Food & Drinks", +// color = Orange.toArgb(), +// icon = "fooddrinks" +// ) +// } +// ) +// } +//} \ No newline at end of file diff --git a/onboarding/src/main/java/com/ivy/onboarding/old/steps/OnboardingSetCurrency.kt b/onboarding/src/main/java/com/ivy/onboarding/old/steps/OnboardingSetCurrency.kt new file mode 100644 index 0000000..9fe560f --- /dev/null +++ b/onboarding/src/main/java/com/ivy/onboarding/old/steps/OnboardingSetCurrency.kt @@ -0,0 +1,116 @@ +//package com.ivy.onboarding.steps +// +//import androidx.compose.foundation.layout.* +//import androidx.compose.material.Text +//import androidx.compose.runtime.* +//import androidx.compose.ui.Alignment +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.res.stringResource +//import androidx.compose.ui.text.font.FontWeight +//import androidx.compose.ui.tooling.preview.Preview +//import androidx.compose.ui.unit.dp +//import com.ivy.resources.R +//import com.ivy.data.IvyCurrency +//import com.ivy.design.l0_system.UI +//import com.ivy.design.l0_system.style +//import com.ivy.design.util.IvyPreview +// +//import com.ivy.wallet.ui.theme.GradientIvy +//import com.ivy.wallet.ui.theme.White +//import com.ivy.wallet.ui.theme.components.BackButton +//import com.ivy.wallet.ui.theme.components.CurrencyPicker +//import com.ivy.wallet.ui.theme.components.GradientCutBottom +//import com.ivy.wallet.ui.theme.components.OnboardingButton +//import com.ivy.wallet.utils.setStatusBarDarkTextCompat +// +//@Composable +//fun BoxWithConstraintsScope.OnboardingSetCurrency( +// preselectedCurrency: IvyCurrency, +// onSetCurrency: (IvyCurrency) -> Unit +//) { +// setStatusBarDarkTextCompat(darkText = UI.colors.isLight) +// +// var currency by remember { mutableStateOf(preselectedCurrency) } +// +// Column( +// modifier = Modifier +// .fillMaxSize() +// .statusBarsPadding() +// .navigationBarsPadding(), +// ) { +// Spacer(Modifier.height(16.dp)) +// +// var keyboardVisible by remember { +// mutableStateOf(false) +// } +// +// +// BackButton( +// modifier = Modifier.padding(start = 20.dp) +// ) { +// nav.onBackPressed() +// } +// +// if (!keyboardVisible) { +// Spacer(Modifier.height(24.dp)) +// +// Text( +// modifier = Modifier.padding(horizontal = 32.dp), +// text = stringResource(R.string.set_currency), +// style = UI.typo.h2.style( +// fontWeight = FontWeight.Black +// ) +// ) +// } +// +// Spacer(Modifier.height(24.dp)) +// +// CurrencyPicker( +// modifier = Modifier +// .fillMaxSize(), +// initialSelectedCurrency = null, +// preselectedCurrency = preselectedCurrency, +// includeKeyboardShownInsetSpacer = true, +// lastItemSpacer = 120.dp, +// onKeyboardShown = { keyboardShown -> +// keyboardVisible = keyboardShown +// } +// ) { +// currency = it +// } +// } +// +// GradientCutBottom( +// height = 160.dp +// ) +// +// OnboardingButton( +// Modifier +// .fillMaxWidth() +// .padding(horizontal = 24.dp) +// .align(Alignment.BottomCenter) +// .navigationBarsPadding() +// .padding(bottom = 20.dp), +// +// text = stringResource(R.string.set), +// textColor = White, +// backgroundGradient = GradientIvy, +// hasNext = true, +// enabled = true +// ) { +// onSetCurrency(currency) +// } +// +//} +// +//@Preview +//@Composable +//private fun Preview() { +// IvyPreview { +// OnboardingSetCurrency( +// preselectedCurrency = IvyCurrency.getDefault() +// ) { +// +// } +// } +//} \ No newline at end of file diff --git a/onboarding/src/main/java/com/ivy/onboarding/old/steps/OnboardingSplashLogin.kt b/onboarding/src/main/java/com/ivy/onboarding/old/steps/OnboardingSplashLogin.kt new file mode 100644 index 0000000..024f77b --- /dev/null +++ b/onboarding/src/main/java/com/ivy/onboarding/old/steps/OnboardingSplashLogin.kt @@ -0,0 +1,496 @@ +//package com.ivy.onboarding.steps +// +//import androidx.annotation.DrawableRes +//import androidx.compose.animation.core.animateDp +//import androidx.compose.animation.core.animateFloat +//import androidx.compose.animation.core.updateTransition +//import androidx.compose.foundation.Image +//import androidx.compose.foundation.background +//import androidx.compose.foundation.clickable +//import androidx.compose.foundation.layout.* +//import androidx.compose.foundation.text.ClickableText +//import androidx.compose.material.Text +//import androidx.compose.runtime.* +//import androidx.compose.ui.Alignment +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.draw.alpha +//import androidx.compose.ui.draw.clip +//import androidx.compose.ui.graphics.Color +//import androidx.compose.ui.layout.ContentScale +//import androidx.compose.ui.layout.layout +//import androidx.compose.ui.platform.LocalConfiguration +//import androidx.compose.ui.platform.LocalUriHandler +//import androidx.compose.ui.res.painterResource +//import androidx.compose.ui.res.stringResource +//import androidx.compose.ui.text.SpanStyle +//import androidx.compose.ui.text.buildAnnotatedString +//import androidx.compose.ui.text.font.FontWeight +//import androidx.compose.ui.text.style.TextAlign +//import androidx.compose.ui.text.style.TextDecoration +//import androidx.compose.ui.tooling.preview.Preview +//import androidx.compose.ui.unit.Dp +//import androidx.compose.ui.unit.dp +//import com.ivy.base.Constants +//import com.ivy.resources.R +//import com.ivy.design.l0_system.UI +//import com.ivy.design.l0_system.style +//import com.ivy.design.util.IvyPreview +//import com.ivy.onboarding.OnboardingState +//import com.ivy.wallet.ui.theme.* +//import com.ivy.wallet.ui.theme.components.IvyDividerLine +//import com.ivy.wallet.ui.theme.components.IvyIcon +//import com.ivy.wallet.utils.* +//import kotlin.math.roundToInt +// +//@Composable +//fun BoxWithConstraintsScope.OnboardingSplashLogin( +// onboardingState: OnboardingState, +// opGoogleSignIn: OpResult?, +// +// onLoginWithGoogle: () -> Unit, +// onSkip: () -> Unit, +//) { +// var internalSwitch by remember { mutableStateOf(true) } +// +// val transition = updateTransition( +// targetState = if (!internalSwitch) OnboardingState.LOGIN else onboardingState, +// label = "Splash" +// ) +// +// val logoWidth by transition.animateDp( +// transitionSpec = { +// springBounceSlow() +// }, +// label = "logoWidth" +// ) { +// when (it) { +// OnboardingState.SPLASH -> 113.dp +// else -> 76.dp +// } +// } +// +// val logoHeight by transition.animateDp( +// transitionSpec = { +// springBounceSlow() +// }, +// label = "logoHeight" +// ) { +// when (it) { +// OnboardingState.SPLASH -> 96.dp +// else -> 64.dp +// } +// } +// +// val screenHeight = LocalConfiguration.current.screenHeightDp.dp +// val screenWidth = LocalConfiguration.current.screenWidthDp.dp +// +// val spacerTop by transition.animateDp( +// transitionSpec = { +// springBounceSlow() +// }, +// label = "spacerTop" +// ) { +// when (it) { +// OnboardingState.SPLASH -> { +// (screenHeight.toDensityPx() / 2f - logoHeight.toDensityPx() / 2f).toDensityDp() +// } +// else -> 56.dp +// } +// } +// +// val percentTransition by transition.animateFloat( +// transitionSpec = { +// springBounceSlow() +// }, +// label = "percentTransition" +// ) { +// when (it) { +// OnboardingState.SPLASH -> 0f +// else -> 1f +// } +// } +// +// val marginTextTop by transition.animateDp( +// transitionSpec = { +// springBounceSlow() +// }, +// label = "marginTextTop" +// ) { +// when (it) { +// OnboardingState.SPLASH -> 64.dp +// else -> 40.dp +// } +// } +// +// Column( +// modifier = Modifier +// .fillMaxSize() +// .background(UI.colors.pure) +// .systemBarsPadding() +// .navigationBarsPadding() +// ) { +// Spacer(Modifier.height(spacerTop)) +// +// Image( +// modifier = Modifier +// .size( +// width = logoWidth, +// height = logoHeight +// ) +// .layout { measurable, constraints -> +// val placeable = measurable.measure(constraints) +// +// val xSplash = screenWidth.toPx() / 2f - placeable.width / 2 +// val xLogin = 24.dp.toPx() +// +// +// layout(placeable.width, placeable.height) { +// placeable.placeRelative( +// x = lerp(xSplash, xLogin, percentTransition).roundToInt(), +// y = 0, +// ) +// } +// } +// .clickableNoIndication { +// internalSwitch = !internalSwitch +// }, +// painter = painterResource(id = R.drawable.ivy_wallet_logo), +// contentScale = ContentScale.FillBounds, +// contentDescription = "Ivy Wallet logo" +// ) +// +// Spacer(Modifier.height(marginTextTop)) +// +// Text( +// modifier = Modifier.animateXCenterToLeft( +// screenWidth = screenWidth, +// percentTransition = percentTransition +// ), +// text = "Ivy Wallet", +// style = UI.typo.h2.style( +// color = UI.colorsInverted.pure, +// fontWeight = FontWeight.ExtraBold +// ) +// ) +// +// +// Spacer(modifier = Modifier.height(16.dp)) +// +// Text( +// modifier = Modifier.animateXCenterToLeft( +// screenWidth = screenWidth, +// percentTransition = percentTransition +// ), +// text = stringResource(R.string.your_personal_money_manager), +// style = UI.typo.b2.style( +// color = UI.colorsInverted.pure, +// fontWeight = FontWeight.SemiBold +// ) +// ) +// +// val uriHandler = LocalUriHandler.current +// Text( +// modifier = Modifier +// .animateXCenterToLeft( +// screenWidth = screenWidth, +// percentTransition = percentTransition +// ) +// .clickable { +// openUrl( +// uriHandler = uriHandler, +// url = Constants.URL_IVY_WALLET_REPO +// ) +// } +// .padding(vertical = 8.dp) +// .padding(end = 8.dp), +// text = stringResource(R.string.opensource), +// style = UI.typo.c.style( +// color = Green, +// fontWeight = FontWeight.Bold +// ) +// ) +// +// LoginSection( +// percentTransition = percentTransition, +// +// opGoogleSignIn = opGoogleSignIn, +// onLoginWithGoogle = onLoginWithGoogle, +// onSkip = onSkip +// ) +// } +//} +// +//private fun Modifier.animateXCenterToLeft( +// screenWidth: Dp, +// percentTransition: Float +//): Modifier { +// return this.layout { measurable, constraints -> +// val placeable = measurable.measure(constraints) +// +// layout(placeable.width, placeable.height) { +// val xSplash = screenWidth.toPx() / 2f - placeable.width / 2 +// val xLogin = 32.dp.toPx() +// +// placeable.placeRelative( +// x = lerp(xSplash, xLogin, percentTransition).roundToInt(), +// y = 0 +// ) +// } +// } +//} +// +//@Composable +//private fun LoginSection( +// percentTransition: Float, +// opGoogleSignIn: OpResult?, +// +// onLoginWithGoogle: () -> Unit, +// onSkip: () -> Unit +//) { +// if (percentTransition > 0.01f) { +// Column( +// modifier = Modifier +// .alpha(percentTransition), +// ) { +// Spacer(Modifier.height(16.dp)) +// Spacer(Modifier.weight(1f)) +// +// LoginWithGoogleExplanation() +// +// Spacer(Modifier.height(12.dp)) +// +// LoginButton( +// text = when (opGoogleSignIn) { +// is OpResult.Failure -> stringResource( +// R.string.google_error_try_again, +// opGoogleSignIn.error() +// ) +// OpResult.Loading -> stringResource(R.string.google_signing_in) +// is OpResult.Success -> stringResource(R.string.google_signing_in_success) +// null -> stringResource(R.string.login_with_google) +// }, +// textColor = White, +// backgroundGradient = GradientRed, +// icon = R.drawable.ic_google, +// hasShadow = true, +// onClick = onLoginWithGoogle +// ) +// +// Spacer(Modifier.height(32.dp)) +// +// IvyDividerLine( +// modifier = Modifier +// .padding(horizontal = 24.dp) +// ) +// +// Spacer(Modifier.height(16.dp)) +// +// LocalAccountExplanation() +// +// Spacer(Modifier.height(16.dp)) +// +// LoginButton( +// icon = R.drawable.ic_local_account, +// text = stringResource(R.string.offline_account), +// textColor = UI.colorsInverted.pure, +// backgroundGradient = Gradient.solid(UI.colors.medium), +// hasShadow = false +// ) { +// onSkip() +// } +// +// Spacer(Modifier.weight(1f)) +// Spacer(Modifier.height(16.dp)) +// +// PrivacyPolicyAndTC() +// +// Spacer(Modifier.height(16.dp)) +// } +// } +//} +// +//@Composable +//private fun LoginWithGoogleExplanation() { +// Row( +// verticalAlignment = Alignment.CenterVertically +// ) { +// Spacer(Modifier.width(24.dp)) +// +// IvyIcon( +// icon = R.drawable.ic_secure, +// tint = Green +// ) +// +// Spacer(Modifier.width(4.dp)) +// +// Column { +// Text( +// text = stringResource(R.string.sync_data_ivy_cloud), +// style = UI.typo.c.style( +// color = Green, +// fontWeight = FontWeight.ExtraBold +// ) +// ) +// +// Spacer(Modifier.height(2.dp)) +// +// Text( +// text = stringResource(R.string.data_integrity_protection_warning), +// style = UI.typo.c.style( +// color = UI.colorsInverted.pure, +// fontWeight = FontWeight.Medium +// ) +// ) +// } +// } +//} +// +//@Composable +//private fun LocalAccountExplanation() { +// Text( +// modifier = Modifier.padding(start = 32.dp), +// text = stringResource(R.string.or_enter_with_offline_account), +// style = UI.typo.c.style( +// color = Gray, +// fontWeight = FontWeight.ExtraBold +// ) +// ) +// +// Spacer(Modifier.height(4.dp)) +// +// Text( +// modifier = Modifier.padding(start = 32.dp, end = 32.dp), +// text = stringResource(R.string.offline_warning), +// style = UI.typo.c.style( +// color = Gray, +// fontWeight = FontWeight.Medium +// ) +// ) +//} +// +//@Composable +//private fun PrivacyPolicyAndTC() { +// val terms = stringResource(R.string.terms_conditions) +// val privacy = stringResource(R.string.privacy_policy) +// val text = stringResource(R.string.by_signing_in, terms, privacy) +// +// val tcStart = text.indexOf(terms) +// val tcEnd = tcStart + terms.length +// +// val privacyStart = text.indexOf(privacy) +// val privacyEnd = privacyStart + privacy.length +// +// val annotatedString = buildAnnotatedString { +// append(text) +// +// addStringAnnotation( +// tag = "URL", +// annotation = Constants.URL_TC, +// start = tcStart, +// end = tcEnd +// ) +// +// addStringAnnotation( +// tag = "URL", +// annotation = Constants.URL_PRIVACY_POLICY, +// start = privacyStart, +// end = privacyEnd +// ) +// +// addStyle( +// style = SpanStyle( +// color = Green, +// textDecoration = TextDecoration.Underline +// ), +// start = tcStart, +// end = tcEnd +// ) +// +// addStyle( +// style = SpanStyle( +// color = Green, +// textDecoration = TextDecoration.Underline +// ), +// start = privacyStart, +// end = privacyEnd +// ) +// } +// +// val uriHandler = LocalUriHandler.current +// ClickableText( +// modifier = Modifier +// .fillMaxWidth() +// .padding(horizontal = 32.dp), +// text = annotatedString, +// style = UI.typo.c.style( +// color = UI.colorsInverted.pure, +// fontWeight = FontWeight.Medium, +// textAlign = TextAlign.Center +// ), +// onClick = { +// annotatedString +// .getStringAnnotations("URL", it, it) +// .forEach { stringAnnotation -> +// uriHandler.openUri(stringAnnotation.item) +// } +// } +// ) +//} +// +//@Composable +//private fun LoginButton( +// @DrawableRes icon: Int, +// text: String, +// textColor: Color, +// backgroundGradient: Gradient, +// hasShadow: Boolean, +// onClick: () -> Unit +//) { +// Row( +// modifier = Modifier +// .padding(horizontal = 24.dp) +// .fillMaxWidth() +// .thenIf(hasShadow) { +// drawColoredShadow(backgroundGradient.startColor) +// } +// .clip(UI.shapes.squared) +// .background(backgroundGradient.asHorizontalBrush(), UI.shapes.squared) +// .clickable { +// onClick() +// }, +// verticalAlignment = Alignment.CenterVertically +// ) { +// Spacer(Modifier.width(20.dp)) +// +// IvyIcon( +// icon = icon, +// tint = textColor +// ) +// +// Spacer(Modifier.width(16.dp)) +// +// Text( +// modifier = Modifier.padding(vertical = 20.dp), +// text = text, +// style = UI.typo.b2.style( +// color = textColor, +// fontWeight = FontWeight.ExtraBold +// ) +// ) +// +// Spacer(Modifier.width(20.dp)) +// } +//} +// +//@Preview +//@Composable +//private fun Preview() { +// IvyPreview { +// OnboardingSplashLogin( +// onboardingState = OnboardingState.SPLASH, +// opGoogleSignIn = null, +// onLoginWithGoogle = {}, +// onSkip = {} +// ) +// } +//} \ No newline at end of file diff --git a/onboarding/src/main/java/com/ivy/onboarding/old/steps/OnboardingType.kt b/onboarding/src/main/java/com/ivy/onboarding/old/steps/OnboardingType.kt new file mode 100644 index 0000000..a81ce03 --- /dev/null +++ b/onboarding/src/main/java/com/ivy/onboarding/old/steps/OnboardingType.kt @@ -0,0 +1,134 @@ +//package com.ivy.onboarding.steps +// +//import androidx.compose.foundation.Image +//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.res.painterResource +//import androidx.compose.ui.res.stringResource +//import androidx.compose.ui.text.font.FontWeight +//import androidx.compose.ui.tooling.preview.Preview +//import androidx.compose.ui.unit.dp +//import com.ivy.resources.R +//import com.ivy.design.l0_system.UI +//import com.ivy.design.l0_system.style +//import com.ivy.design.util.IvyPreview +// +//import com.ivy.old.OnboardingProgressSlider +//import com.ivy.wallet.ui.theme.* +//import com.ivy.wallet.ui.theme.components.CloseButton +//import com.ivy.wallet.ui.theme.components.IvyOutlinedButtonFillMaxWidth +//import com.ivy.wallet.ui.theme.components.OnboardingButton +// +//@Composable +//fun OnboardingType( +// +// onStartImport: () -> Unit, +// onStartFresh: () -> Unit, +//) { +// Column( +// modifier = Modifier +// .fillMaxSize() +// .statusBarsPadding() +// .navigationBarsPadding() +// ) { +// Spacer(Modifier.height(16.dp)) +// +// +// CloseButton( +// modifier = Modifier.padding(start = 20.dp) +// ) { +// nav.onBackPressed() +// } +// +// Spacer(Modifier.height(24.dp)) +// +// Text( +// modifier = Modifier.padding(horizontal = 32.dp), +// text = stringResource(R.string.import_csv_file), +// style = UI.typo.h2.style( +// fontWeight = FontWeight.Black +// ) +// ) +// +// Spacer(Modifier.height(8.dp)) +// +// Text( +// modifier = Modifier.padding(horizontal = 32.dp), +// text = stringResource(R.string.from_ivy_or_another_app), +// style = UI.typoSecond.b2.style( +// fontWeight = FontWeight.Bold, +// color = Gray +// ) +// ) +// +// Spacer(Modifier.weight(1f)) +// +// Image( +// modifier = Modifier.align(Alignment.CenterHorizontally), +// painter = painterResource(id = R.drawable.onboarding_illustration_import), +// contentDescription = "import illustration" +// ) +// +// OnboardingProgressSlider( +// modifier = Modifier.align(Alignment.CenterHorizontally), +// selectedStep = 0, +// stepsCount = 4, +// selectedColor = Orange +// ) +// +// Spacer(Modifier.weight(1f)) +// +// Text( +// modifier = Modifier.padding(horizontal = 32.dp), +// text = stringResource(R.string.importing_another_time_warning), +// style = UI.typo.b2.style( +// fontWeight = FontWeight.Bold +// ) +// ) +// +// Spacer(Modifier.height(24.dp)) +// +// IvyOutlinedButtonFillMaxWidth( +// modifier = Modifier +// .padding(horizontal = 16.dp), +// text = stringResource(R.string.import_backup_file), +// iconStart = R.drawable.ic_export_csv, +// iconTint = Green, +// textColor = Green +// ) { +// onStartImport() +// } +// +// Spacer(Modifier.weight(1f)) +// +// OnboardingButton( +// modifier = Modifier +// .padding(horizontal = 16.dp) +// .fillMaxWidth(), +// text = stringResource(R.string.start_fresh), +// textColor = White, +// backgroundGradient = GradientIvy, +// hasNext = true, +// enabled = true +// ) { +// onStartFresh() +// } +// +// Spacer(Modifier.height(24.dp)) +// } +//} +// +//@Preview +//@Composable +//private fun Preview() { +// IvyPreview { +// OnboardingType( +// onStartImport = {} +// ) { +// +// } +// } +//} \ No newline at end of file diff --git a/onboarding/src/main/java/com/ivy/onboarding/old/viewmodel/OnboardingRouter.kt b/onboarding/src/main/java/com/ivy/onboarding/old/viewmodel/OnboardingRouter.kt new file mode 100644 index 0000000..16d5d1f --- /dev/null +++ b/onboarding/src/main/java/com/ivy/onboarding/old/viewmodel/OnboardingRouter.kt @@ -0,0 +1,255 @@ +//package com.ivy.onboarding.viewmodel +// +//import androidx.lifecycle.MutableLiveData +//import com.ivy.base.AccountBalance +//import com.ivy.core.ui.temp.trash.IvyWalletCtx +//import com.ivy.data.CategoryOld +//import com.ivy.data.IvyCurrency +// +//import com.ivy.onboarding.OnboardingState +//import com.ivy.wallet.domain.deprecated.logic.currency.ExchangeRatesLogic +//import com.ivy.wallet.domain.deprecated.logic.model.CreateAccountData +//import com.ivy.wallet.domain.deprecated.logic.model.CreateCategoryData +//import com.ivy.wallet.domain.deprecated.logic.notification.TransactionReminderLogic +//import com.ivy.wallet.domain.deprecated.sync.IvySync +//import com.ivy.wallet.io.persistence.SharedPrefs +//import com.ivy.wallet.io.persistence.dao.AccountDao +//import com.ivy.wallet.io.persistence.dao.CategoryDao +//import com.ivy.wallet.utils.OpResult +//import com.ivy.wallet.utils.ioThread +//import kotlinx.coroutines.CoroutineScope +//import kotlinx.coroutines.delay +// +//class OnboardingRouter( +// private val _opGoogleSignIn: MutableLiveData?>, +// private val _state: MutableLiveData, +// private val _accounts: MutableLiveData>, +// private val _accountSuggestions: MutableLiveData>, +// private val _categories: MutableLiveData>, +// private val _categorySuggestions: MutableLiveData>, +// +// private val ivyContext: IvyWalletCtx, +// private val +// private val exchangeRatesLogic: ExchangeRatesLogic, +// private val accountDao: AccountDao, +// private val sharedPrefs: SharedPrefs, +// private val transactionReminderLogic: TransactionReminderLogic, +// private val ivySync: IvySync, +// private val preloadDataLogic: PreloadDataLogic, +// private val categoryDao: CategoryDao, +// private val logoutLogic: LogoutLogic +//) { +// +// var isLoginCache = false +// +// fun initBackHandling( +// viewModelScope: CoroutineScope, +// restartOnboarding: () -> Unit +// ) { +//// nav.onBackPressed[screen] = { +//// when (_state.value) { +//// OnboardingState.SPLASH -> { +//// //do nothing, consume back +//// true +//// } +//// OnboardingState.LOGIN -> { +//// //let the user exit the app +//// false +//// } +//// OnboardingState.CHOOSE_PATH -> { +//// _state.value = OnboardingState.LOGIN +//// true +//// } +//// OnboardingState.CURRENCY -> { +//// if (isLoginCache) { +//// //user with Ivy account +//// viewModelScope.launch { +//// logoutLogic.logout() +//// isLoginCache = false +//// restartOnboarding() +//// _state.value = OnboardingState.LOGIN +//// } +//// } else { +//// //fresh user +//// _state.value = OnboardingState.CHOOSE_PATH +//// } +//// true +//// } +//// OnboardingState.ACCOUNTS -> { +//// _state.value = OnboardingState.CURRENCY +//// true +//// } +//// OnboardingState.CATEGORIES -> { +//// _state.value = OnboardingState.ACCOUNTS +//// true +//// } +//// null -> { +//// //do nothing, consume back +//// true +//// } +//// } +//// } +// } +// +// //------------------------------------- Step 0 - Splash ---------------------------------------- +// suspend fun splashNext() { +// if (_state.value == OnboardingState.SPLASH) { +// delay(1000) +// +// _state.value = OnboardingState.LOGIN +// } +// } +// //------------------------------------- Step 0 ------------------------------------------------- +// +// +// //------------------------------------- Step 1 - Login ----------------------------------------- +// suspend fun googleLoginNext() { +// ioThread { +// ivySync.sync() +// } +// +// if (isLogin()) { +// //Route logged user +// _state.value = OnboardingState.CURRENCY +// } else { +// //Route new user +// _state.value = OnboardingState.CHOOSE_PATH +// } +// } +// +// private suspend fun isLogin(): Boolean { +// isLoginCache = ioThread { accountDao.findAllSuspend().isNotEmpty() } +// return isLoginCache +// } +// +// suspend fun offlineAccountNext() { +// _state.value = OnboardingState.CHOOSE_PATH +// } +// //------------------------------------- Step 1 ------------------------------------------------- +// +// +// //------------------------------------- Step 2 - Choose path ----------------------------------- +// fun startImport() { +//// nav.navigateTo( +//// Import( +//// launchedFromOnboarding = true +//// ) +//// ) +// } +// +// fun importSkip() { +// _state.value = OnboardingState.CURRENCY +// } +// +// fun importFinished(success: Boolean) { +// if (success) { +// _state.value = OnboardingState.CURRENCY +// } +// } +// +// fun startFresh() { +// _state.value = OnboardingState.CURRENCY +// } +// //------------------------------------- Step 2 ------------------------------------------------- +// +// +// //------------------------------------- Step 3 - Currency -------------------------------------- +// suspend fun setBaseCurrencyNext( +// baseCurrency: IvyCurrency, +// accountsWithBalance: suspend () -> List, +// ) { +// routeToAccounts( +// baseCurrency = baseCurrency, +// accountsWithBalance = accountsWithBalance +// ) +// +// if (isLogin()) { +// completeOnboarding(baseCurrency = baseCurrency) +// } +// } +// //------------------------------------- Step 3 ------------------------------------------------- +// +// +// //------------------------------------- Step 4 - Accounts -------------------------------------- +// suspend fun accountsNext() { +// routeToCategories() +// } +// +// suspend fun accountsSkip() { +// routeToCategories() +// +// ioThread { +// preloadDataLogic.preloadAccounts() +// ivySync.syncAccounts() +// } +// } +// //------------------------------------- Step 4 ------------------------------------------------- +// +// +// //------------------------------------- Step 5 - Categories ------------------------------------ +// suspend fun categoriesNext(baseCurrency: IvyCurrency?) { +// completeOnboarding(baseCurrency = baseCurrency) +// } +// +// suspend fun categoriesSkip(baseCurrency: IvyCurrency?) { +// completeOnboarding(baseCurrency = baseCurrency) +// +// ioThread { +// preloadDataLogic.preloadCategories() +// ivySync.syncCategories() +// } +// +// } +// //------------------------------------- Step 5 ------------------------------------------------- +// +// //-------------------------------------- Routes ------------------------------------------------ +// private suspend fun routeToAccounts( +// baseCurrency: IvyCurrency, +// accountsWithBalance: suspend () -> List, +// ) { +// val accounts = accountsWithBalance() +// _accounts.value = accounts +// +// _accountSuggestions.value = +// preloadDataLogic.accountSuggestions(baseCurrency.code) +// _state.value = OnboardingState.ACCOUNTS +// } +// +// private suspend fun routeToCategories() { +// _categories.value = ioThread { categoryDao.findAllSuspend().map { it.toDomain() } }!! +// _categorySuggestions.value = preloadDataLogic.categorySuggestions() +// +// _state.value = OnboardingState.CATEGORIES +// } +// +// +// private suspend fun completeOnboarding( +// baseCurrency: IvyCurrency? +// ) { +// sharedPrefs.putBoolean(SharedPrefs.ONBOARDING_COMPLETED, true) +// +// navigateOutOfOnboarding() +// +// //the rest below is not UI stuff so I don't care +// ioThread { +// transactionReminderLogic.scheduleReminder() +// +// exchangeRatesLogic.sync( +// baseCurrency = baseCurrency?.code ?: IvyCurrency.getDefault().code +// ) +// } +// +// resetState() +// } +// +// private fun resetState() { +// _state.value = OnboardingState.SPLASH +// _opGoogleSignIn.value = null +// } +// +// private fun navigateOutOfOnboarding() { +// nav.resetBackStack() +//// nav.navigateTo(Main) +// } +// //-------------------------------------- Routes ------------------------------------------------ +//} \ No newline at end of file diff --git a/onboarding/src/main/java/com/ivy/onboarding/old/viewmodel/OnboardingViewModel.kt b/onboarding/src/main/java/com/ivy/onboarding/old/viewmodel/OnboardingViewModel.kt new file mode 100644 index 0000000..e68da5b --- /dev/null +++ b/onboarding/src/main/java/com/ivy/onboarding/old/viewmodel/OnboardingViewModel.kt @@ -0,0 +1,359 @@ +//package com.ivy.onboarding.viewmodel +// +//import androidx.lifecycle.MutableLiveData +//import androidx.lifecycle.ViewModel +//import androidx.lifecycle.viewModelScope +//import com.ivy.base.AccountBalance +//import com.ivy.core.ui.temp.trash.IvyWalletCtx +//import com.ivy.data.* +//import com.ivy.frp.test.TestIdlingResource +// +//import com.ivy.onboarding.OnboardingState +//import com.ivy.wallet.domain.action.account.AccountsActOld +//import com.ivy.wallet.domain.action.category.CategoriesActOld +//import com.ivy.wallet.domain.deprecated.logic.* +//import com.ivy.wallet.domain.deprecated.logic.currency.ExchangeRatesLogic +//import com.ivy.wallet.domain.deprecated.logic.model.CreateAccountData +//import com.ivy.wallet.domain.deprecated.logic.model.CreateCategoryData +//import com.ivy.wallet.domain.deprecated.logic.notification.TransactionReminderLogic +//import com.ivy.wallet.domain.deprecated.sync.IvySync +//import com.ivy.wallet.io.network.IvySession +//import com.ivy.wallet.io.network.RestClient +//import com.ivy.wallet.io.network.request.auth.GoogleSignInRequest +//import com.ivy.wallet.io.persistence.SharedPrefs +//import com.ivy.wallet.io.persistence.dao.AccountDao +//import com.ivy.wallet.io.persistence.dao.CategoryDao +//import com.ivy.wallet.io.persistence.dao.SettingsDao +//import com.ivy.wallet.io.persistence.data.toEntity +//import com.ivy.wallet.utils.OpResult +//import com.ivy.wallet.utils.asLiveData +//import com.ivy.wallet.utils.ioThread +//import dagger.hilt.android.lifecycle.HiltViewModel +//import kotlinx.coroutines.launch +//import timber.log.Timber +//import javax.inject.Inject +// +//@HiltViewModel +//class OnboardingViewModel @Inject constructor( +// private val ivyContext: IvyWalletCtx, +// private val +// private val accountDao: AccountDao, +// private val settingsDao: SettingsDao, +// private val restClient: RestClient, +// private val session: IvySession, +// private val accountLogic: WalletAccountLogic, +// private val categoryCreator: CategoryCreator, +// private val categoryDao: CategoryDao, +// private val accountCreator: AccountCreator, +// +// private val accountsAct: AccountsActOld, +// private val categoriesAct: CategoriesActOld, +// +// //Only OnboardingRouter stuff +// sharedPrefs: SharedPrefs, +// ivySync: IvySync, +// transactionReminderLogic: TransactionReminderLogic, +// preloadDataLogic: PreloadDataLogic, +// exchangeRatesLogic: ExchangeRatesLogic, +// logoutLogic: LogoutLogic +//) : ViewModel() { +// +// private val _state = MutableLiveData(OnboardingState.SPLASH) +// val state = _state.asLiveData() +// +// private val _currency = MutableLiveData() +// val currency = _currency.asLiveData() +// +// private val _opGoogleSignIn = MutableLiveData?>() +// val opGoogleSignIn = _opGoogleSignIn.asLiveData() +// +// private val _accounts = MutableLiveData>() +// val accounts = _accounts.asLiveData() +// +// private val _accountSuggestions = MutableLiveData>() +// val accountSuggestions = _accountSuggestions.asLiveData() +// +// private val _categories = MutableLiveData>() +// val categories = _categories.asLiveData() +// +// private val _categorySuggestions = MutableLiveData>() +// val categorySuggestions = _categorySuggestions.asLiveData() +// +// private val router = OnboardingRouter( +// _state = _state, +// _opGoogleSignIn = _opGoogleSignIn, +// _accounts = _accounts, +// _accountSuggestions = _accountSuggestions, +// _categories = _categories, +// _categorySuggestions = _categorySuggestions, +// +// ivyContext = ivyContext, +// +// exchangeRatesLogic = exchangeRatesLogic, +// accountDao = accountDao, +// sharedPrefs = sharedPrefs, +// categoryDao = categoryDao, +// ivySync = ivySync, +// preloadDataLogic = preloadDataLogic, +// transactionReminderLogic = transactionReminderLogic, +// logoutLogic = logoutLogic +// ) +// +// fun start(isSystemDarkMode: Boolean) { +// viewModelScope.launch { +// TestIdlingResource.increment() +// +// initiateSettings(isSystemDarkMode) +// +//// router.initBackHandling( +//// screen = screen, +//// viewModelScope = viewModelScope, +//// restartOnboarding = { +//// start(isSystemDarkMode) +//// } +//// ) +// +// router.splashNext() +// +// TestIdlingResource.decrement() +// } +// } +// +// private suspend fun initiateSettings(isSystemDarkMode: Boolean) { +// val defaultCurrency = IvyCurrency.getDefault() +// _currency.value = defaultCurrency +// +// ioThread { +// TestIdlingResource.increment() +// +// if (settingsDao.findAll().isEmpty()) { +// settingsDao.save( +// Settings( +// theme = if (isSystemDarkMode) Theme.DARK else Theme.LIGHT, +// name = "", +// baseCurrency = defaultCurrency.code, +// bufferAmount = 1000.0.toBigDecimal() +// ).toEntity() +// ) +// } +// +// TestIdlingResource.decrement() +// } +// } +// +// //Step 1 --------------------------------------------------------------------------------------- +// fun loginWithGoogle() { +// ivyContext.googleSignIn { idToken -> +// if (idToken != null) { +// _opGoogleSignIn.value = OpResult.loading() +// viewModelScope.launch { +// TestIdlingResource.increment() +// +// try { +// loginWithGoogleOnServer(idToken) +// +// router.googleLoginNext() +// +// _opGoogleSignIn.value = null //reset login with Google operation state +// } catch (e: Exception) { +// e.printStackTrace() +// Timber.e("Login with Google failed on Ivy server - ${e.message}") +// _opGoogleSignIn.value = OpResult.failure(e) +// } +// +// TestIdlingResource.decrement() +// } +// } else { +// Timber.e("Login with Google failed while getting idToken") +// _opGoogleSignIn.value = OpResult.faliure("Login with Google failed, try again.") +// } +// } +// } +// +// private suspend fun loginWithGoogleOnServer(idToken: String) { +// TestIdlingResource.increment() +// +// val authResponse = restClient.authService.googleSignIn( +// GoogleSignInRequest( +// googleIdToken = idToken, +// fcmToken = "n/a" +// ) +// ) +// +// ioThread { +// session.initiate(authResponse) +// +// settingsDao.save( +// settingsDao.findFirstSuspend().copy( +// name = authResponse.user.firstName +// ) +// ) +// } +// +// _opGoogleSignIn.value = OpResult.success(Unit) +// +// TestIdlingResource.decrement() +// } +// +// fun loginOfflineAccount() { +// viewModelScope.launch { +// TestIdlingResource.increment() +// router.offlineAccountNext() +// TestIdlingResource.decrement() +// } +// } +// //Step 1 --------------------------------------------------------------------------------------- +// +// +// //Step 2 --------------------------------------------------------------------------------------- +// fun startImport() { +// router.startImport() +// } +// +// fun importSkip() { +// router.importSkip() +// } +// +// fun importFinished(success: Boolean) { +// router.importFinished(success) +// } +// +// fun startFresh() { +// router.startFresh() +// } +// //Step 2 --------------------------------------------------------------------------------------- +// +// +// fun setBaseCurrency(baseCurrency: IvyCurrency) { +// viewModelScope.launch { +// TestIdlingResource.increment() +// +// updateBaseCurrency(baseCurrency) +// +// router.setBaseCurrencyNext( +// baseCurrency = baseCurrency, +// accountsWithBalance = { accountsWithBalance() } +// ) +// +// TestIdlingResource.decrement() +// } +// } +// +// private suspend fun updateBaseCurrency(baseCurrency: IvyCurrency) { +// ioThread { +// TestIdlingResource.increment() +// +// settingsDao.save( +// settingsDao.findFirstSuspend().copy( +// currency = baseCurrency.code +// ) +// ) +// +// TestIdlingResource.decrement() +// } +// _currency.value = baseCurrency +// } +// +// //--------------------- Accounts --------------------------------------------------------------- +// fun editAccount(account: AccountOld, newBalance: Double) { +// viewModelScope.launch { +// TestIdlingResource.increment() +// +// accountCreator.editAccount(account, newBalance) { +// _accounts.value = accountsWithBalance() +// } +// +// TestIdlingResource.decrement() +// } +// } +// +// +// fun createAccount(data: CreateAccountData) { +// viewModelScope.launch { +// TestIdlingResource.increment() +// +// accountCreator.createAccount(data) { +// _accounts.value = accountsWithBalance() +// } +// +// TestIdlingResource.decrement() +// } +// } +// +// private suspend fun accountsWithBalance(): List = ioThread { +// accountsAct(Unit) +// .map { +// AccountBalance( +// account = it, +// balance = ioThread { accountLogic.calculateAccountBalance(it) } +// ) +// } +// } +// +// fun onAddAccountsDone() { +// viewModelScope.launch { +// TestIdlingResource.increment() +// +// router.accountsNext() +// +// TestIdlingResource.decrement() +// } +// } +// +// fun onAddAccountsSkip() { +// viewModelScope.launch { +// TestIdlingResource.increment() +// +// router.accountsSkip() +// +// TestIdlingResource.decrement() +// } +// } +// //--------------------- Accounts --------------------------------------------------------------- +// +// //---------------------------- Categories ------------------------------------------------------ +// fun editCategory(updatedCategory: CategoryOld) { +// viewModelScope.launch { +// TestIdlingResource.increment() +// +// categoryCreator.editCategory(updatedCategory) { +// _categories.value = categoriesAct(Unit)!! +// } +// +// TestIdlingResource.decrement() +// } +// } +// +// fun createCategory(data: CreateCategoryData) { +// viewModelScope.launch { +// TestIdlingResource.increment() +// +// categoryCreator.createCategory(data) { +// _categories.value = categoriesAct(Unit)!! +// } +// +// TestIdlingResource.decrement() +// } +// } +// +// fun onAddCategoriesDone() { +// viewModelScope.launch { +// TestIdlingResource.increment() +// +// router.categoriesNext(baseCurrency = currency.value) +// +// TestIdlingResource.decrement() +// } +// } +// +// fun onAddCategoriesSkip() { +// viewModelScope.launch { +// TestIdlingResource.increment() +// +// router.categoriesSkip(baseCurrency = currency.value) +// +// TestIdlingResource.decrement() +// } +// } +// //---------------------------- Categories ------------------------------------------------------ +//} \ No newline at end of file diff --git a/onboarding/src/main/java/com/ivy/onboarding/screen/debug/OnboardingDebugEvent.kt b/onboarding/src/main/java/com/ivy/onboarding/screen/debug/OnboardingDebugEvent.kt new file mode 100644 index 0000000..587d040 --- /dev/null +++ b/onboarding/src/main/java/com/ivy/onboarding/screen/debug/OnboardingDebugEvent.kt @@ -0,0 +1,8 @@ +package com.ivy.onboarding.screen.debug + +import com.ivy.data.CurrencyCode + +sealed interface OnboardingDebugEvent { + data class SetBaseCurrency(val currency: CurrencyCode) : OnboardingDebugEvent + object FinishOnboarding : OnboardingDebugEvent +} \ No newline at end of file diff --git a/onboarding/src/main/java/com/ivy/onboarding/screen/debug/OnboardingDebugScreen.kt b/onboarding/src/main/java/com/ivy/onboarding/screen/debug/OnboardingDebugScreen.kt new file mode 100644 index 0000000..12eb462 --- /dev/null +++ b/onboarding/src/main/java/com/ivy/onboarding/screen/debug/OnboardingDebugScreen.kt @@ -0,0 +1,89 @@ +package com.ivy.onboarding.screen.debug + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ivy.core.ui.currency.CurrencyPickerModal +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.* +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton + +@Composable +fun BoxScope.OnboardingDebug() { + val viewModel: OnboardingDebugViewModel = hiltViewModel() + val state by viewModel.uiState.collectAsState() + + val currencyPickerModal = rememberIvyModal() + + LaunchedEffect(Unit) { + currencyPickerModal.show() + } + + ColumnRoot { + SpacerVer(height = 24.dp) + H1( + modifier = Modifier.padding(horizontal = 16.dp), + text = "Onboarding" + ) + SpacerVer(height = 12.dp) + Caption( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = "Important: This is NOT a real onboarding," + + " it's just setup a quick hack for you to test the new Ivy Wallet app." + + " This screen is only for debugging purposes. It will be removed in production.", + color = UI.colors.red + ) + SpacerWeight(weight = 1f) + H2( + modifier = Modifier.padding(horizontal = 16.dp), + text = "Base currency: ${state.baseCurrency}" + ) + SpacerVer(height = 24.dp) + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = if (state.baseCurrency.isNotBlank()) + Visibility.Medium else Visibility.High, + feeling = Feeling.Positive, + text = state.baseCurrency.takeIf { it.isNotBlank() } ?: "Pick one", + onClick = { + currencyPickerModal.show() + } + ) + SpacerVer(height = 24.dp) + if (state.baseCurrency.isNotBlank()) { + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Focused, + feeling = Feeling.Positive, + text = "Finish onboarding", + icon = null + ) { + viewModel.onEvent(OnboardingDebugEvent.FinishOnboarding) + } + } + SpacerWeight(weight = 1f) + } + + CurrencyPickerModal( + modal = currencyPickerModal, + initialCurrency = state.baseCurrency, + onCurrencyPick = { + viewModel.onEvent(OnboardingDebugEvent.SetBaseCurrency(it)) + } + ) +} \ No newline at end of file diff --git a/onboarding/src/main/java/com/ivy/onboarding/screen/debug/OnboardingDebugState.kt b/onboarding/src/main/java/com/ivy/onboarding/screen/debug/OnboardingDebugState.kt new file mode 100644 index 0000000..c5c7a84 --- /dev/null +++ b/onboarding/src/main/java/com/ivy/onboarding/screen/debug/OnboardingDebugState.kt @@ -0,0 +1,9 @@ +package com.ivy.onboarding.screen.debug + +import androidx.compose.runtime.Immutable +import com.ivy.data.CurrencyCode + +@Immutable +data class OnboardingDebugState( + val baseCurrency: CurrencyCode +) \ No newline at end of file diff --git a/onboarding/src/main/java/com/ivy/onboarding/screen/debug/OnboardingDebugViewModel.kt b/onboarding/src/main/java/com/ivy/onboarding/screen/debug/OnboardingDebugViewModel.kt new file mode 100644 index 0000000..390f372 --- /dev/null +++ b/onboarding/src/main/java/com/ivy/onboarding/screen/debug/OnboardingDebugViewModel.kt @@ -0,0 +1,44 @@ +package com.ivy.onboarding.screen.debug + +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.action.settings.basecurrency.BaseCurrencyFlow +import com.ivy.core.domain.action.settings.basecurrency.WriteBaseCurrencyAct +import com.ivy.navigation.Navigator +import com.ivy.navigation.destinations.Destination +import com.ivy.onboarding.action.WriteOnboardingFinishedAct +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@HiltViewModel +class OnboardingDebugViewModel @Inject constructor( + private val writeBaseCurrencyAct: WriteBaseCurrencyAct, + private val writeOnboardingFinishedAct: WriteOnboardingFinishedAct, + baseCurrencyFlow: BaseCurrencyFlow, + private val navigator: Navigator, +) : SimpleFlowViewModel() { + override val initialUi = OnboardingDebugState(baseCurrency = "") + + override val uiFlow: Flow = baseCurrencyFlow().map { + OnboardingDebugState(baseCurrency = it) + } + + override suspend fun handleEvent(event: OnboardingDebugEvent) = when (event) { + OnboardingDebugEvent.FinishOnboarding -> finishOnboarding() + is OnboardingDebugEvent.SetBaseCurrency -> setBaseCurrency(event) + } + + private suspend fun setBaseCurrency(event: OnboardingDebugEvent.SetBaseCurrency) { + writeBaseCurrencyAct(event.currency) + } + + private suspend fun finishOnboarding() { + writeOnboardingFinishedAct(true) + navigator.navigate(Destination.home.destination(Unit)) { + popUpTo(Destination.debug.route) { + inclusive = true + } + } + } +} \ No newline at end of file diff --git a/parser/.gitignore b/parser/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/parser/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/parser/README.md b/parser/README.md new file mode 100644 index 0000000..9d83afd --- /dev/null +++ b/parser/README.md @@ -0,0 +1,10 @@ +# Parser + +A functional recursive descent parser used for implementing +Ivy Wallet's calculator expressions and formulas. + +It's motivated by and +implements [FUNCTIONAL PEARLS Monadic Parsing in Haskell](https://www.cs.nott.ac.uk/~pszgmh/pearl.pdf) +in Kotlin. + +The purpose of this module is to allow its users to parse any arbitrary text in a concise manner. \ No newline at end of file diff --git a/parser/build.gradle.kts b/parser/build.gradle.kts new file mode 100644 index 0000000..a0e634a --- /dev/null +++ b/parser/build.gradle.kts @@ -0,0 +1,15 @@ +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + Testing() +} \ No newline at end of file diff --git a/parser/src/main/AndroidManifest.xml b/parser/src/main/AndroidManifest.xml new file mode 100644 index 0000000..45d62f8 --- /dev/null +++ b/parser/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/parser/src/main/java/com/ivy/parser/LexicalCombinators.kt b/parser/src/main/java/com/ivy/parser/LexicalCombinators.kt new file mode 100644 index 0000000..756f372 --- /dev/null +++ b/parser/src/main/java/com/ivy/parser/LexicalCombinators.kt @@ -0,0 +1,29 @@ +package com.ivy.parser + +/** + * Parses whitespace ' ' (space), '\t' tab or '\n'. + * This operation never fails. For empty text, simple returns empty list of chars. + */ +fun whitespace(): Parser> = zeroOrMany(sat { it.isWhitespace() }) + +/** + * Parses a thing and then removes the whitespace after it. + * + * **Example** + * ``` + * val parser = token(string("okay")) + * parser("okay Google") + * // ParserResult(value="okay", leftover = "Google") + * ``` + */ +fun token(parser: Parser): Parser = parser.apply { t -> + whitespace().apply { + pure(t) + } +} + +/** + * Parses a string and then remove the whitespace after it. + * See [token]. + */ +fun symbolicToken(str: String): Parser = token(string(str)) \ No newline at end of file diff --git a/parser/src/main/java/com/ivy/parser/Parser.kt b/parser/src/main/java/com/ivy/parser/Parser.kt new file mode 100644 index 0000000..deb5649 --- /dev/null +++ b/parser/src/main/java/com/ivy/parser/Parser.kt @@ -0,0 +1,146 @@ +package com.ivy.parser + +/** + * Motivated by FUNCTIONAL PEARL + * Monadic parsing in Haskell + * by Graham Hutton & Erik Meijer + */ +// Paper: https://www.cs.nott.ac.uk/~pszgmh/pearl.pdf + +/** + * @param value the parsed value + * @param leftover the text left to parse + */ +data class ParseResult( + val value: T, + val leftover: String, +) + +/** + * Parser monad which accepts text (String) + * and returns a list of parse interpretations or [] on failure. + */ +typealias Parser = (String) -> List> + +// region Result builders +/** + * Use for successfully parsing a value. + * Wraps a value in a parse w/o modifying the text being parsed. + * + * **Haskell equivalent:** + * - Applicative#pure() + * - Monad#return() + */ +fun pure(value: T): Parser = { text -> + listOf(ParseResult(value, text)) +} + +/** + * Returns a parser indicating failure which will fail all parsers applied after it. + */ +fun fail(): Parser = { emptyList() } +// endregion + +/** + * Applies a parser and invokes the parser with parsed value if it was successful. + * In case of multiple successful parsing returned + * from this parse or next parser, they're flattened. + * + * **FP equivalent:** + * - Monad#bind + * - Scala's flatMap{} + * + * **Example:** + * ``` + * // parse the text "Jetpack Compose" or "Jetpack+Compose" + * fun jetpackComposeParser() = string("Jetpack").apply { jetpack -> + * (char(' ') or char('+')).apply { //ignored divider + * string("Compose").apply { compose -> + * pure(jetpack + compose) + * } + * } + * } + * ``` + * + * @receiver the first parser to apply _(Parser 1)_. + * @param nextParser a function creating the next parser which will be applied only if + * _Parser 1_ was successful. + * @return a new parser that chains _"Parser 1 -> Parser 2"_. + * + */ +fun Parser.apply( + nextParser: (T) -> Parser +): Parser = { string -> + val res1 = this(string) // apply parser 1 + + /* + * Parser 1 = "this" + * Parser 2 = "nextParser" + * # Case A: + * Parser 1 fails, meaning it returns res1 = [] + * => Parser 2 won't be invoked, [] (failure) is returned + * + * # Case B: + * Parser 1 parses only one thing: res1 = [ParseResult] + * => Parser 2 will be invoked only once, [f(ParseResult)] + * + * # Case C: + * Parser 1 parses multiple things: res1 = [pr1, pr2, ... , prn] + * => Parser 2 (`f`) will be invoked N times. + * If Parser 2 also returns multiple results they'll be flattened and returned [n*m] + * where n = Parser 1 results and m = Parser 2 results for each n. + */ + res1.flatMap { + // Apply Parser 2 to each successfully parsed value by Parser 1 and its leftover + nextParser(it.value).invoke(it.leftover) + } +} + +// region Read a not parsed character +/** + * A parser that reads one character from the text left to parse. + * Fails if the text is empty. + */ +fun item(): Parser = { string -> + if (string.isNotEmpty()) { + // return the first character as value and the rest as leftover + listOf( + ParseResult( + value = string.first(), + leftover = string.drop(1) + ) + ) + } else emptyList() +} +// endregion + +// region Core: Parse char, string & a symbol satisfying a predicate +/** + * Parses a char if it satisfies a given predicate. + * @param predicate returns whether the parsing is successful. + * @return a parser that parses a character for a predicate. + */ +fun sat(predicate: (Char) -> Boolean): Parser = { string -> + item().apply { char -> + if (predicate(char)) pure(char) else fail() + }.invoke(string) +} + +/** + * Parses a specific character. + * @param c the character to parse + * @return a parser that parses a character + */ +fun char(c: Char): Parser = sat { it == c } + +fun string(str: String): Parser = { string -> + if (str.isEmpty()) pure("").invoke(string) else { + // recurse + char(str.first()).apply { c -> + string(str.drop(1)).apply { cs -> + pure(c + cs) + } + }.invoke(string) + } +} +// endregion \ No newline at end of file diff --git a/parser/src/main/java/com/ivy/parser/RecursionCombinators.kt b/parser/src/main/java/com/ivy/parser/RecursionCombinators.kt new file mode 100644 index 0000000..d25c5ee --- /dev/null +++ b/parser/src/main/java/com/ivy/parser/RecursionCombinators.kt @@ -0,0 +1,127 @@ +package com.ivy.parser + +/** + * Builds a new parser that do **Parser 1 || Parser 2**. Tries _Parser 1_ and + * if it succeeds returns its result. If _Parser 1_ fails executes _Parser 2_. + * Left associative operator for chaining parsers. + * + * **Example** + * ``` + * // parser Calculator operation + * enum Operation { Plus, Minus, Multiple, Divide } + * fun operationParser(): Parser = + * (char('+') or char('-') or char('*') or char('-')).apply { opSymbol -> + * when(opSymbol) { + * '+' -> Operation.Plus + * '-' -> Operation.Minus + * '*' -> Operation.Multiple + * '/' -> Operation.Divide + * else -> error("should NOT happen!") + * } + * } + * ``` + * + * @receiver the first parser to apply _(Parser 1)_. + * @param parser2 the second parser to apply _(Parser 2)_. + * @return a combined OR parser: _Parser 1_ **||** _Parser 2_. + */ +infix fun Parser.or(parser2: Parser): Parser = { text -> + this(text).ifEmpty { parser2(text) } +} + +/** + * Applies _Parser 1_ then _Parser 2_ and returns their results combined. + * + * **Example:** + * ``` + * fun parseAsciiA(): Parser = char('A').apply { char -> + * char.toByte().toInt() + * } + * + * fun combined(): Parser = char('A') + parseAsciiA() + * // ['A', 65] + * ``` + * + * @receiver _Parser 1_ + * @param parser2 _Parser 2_ + * @return the combined result or Parser 1 + Parser 2: [[Parser 1]] + [[Parser 2]] + */ +operator fun Parser.plus(parser2: Parser): Parser = { text -> + this(text) + parser2(text) +} + +/** + * Takes only the first variation of a parsing. + * Parsers always return a list of results which may contain more than one parsings. + * @return a parser that: + * **[[ParserRes1, ParserRes2, ParserResN]] => [[ParserRes1]]** + */ +fun Parser.first(): Parser = { text -> + val res = this(text) + res.take(1) +} + +// region Occurrences +/** + * Zero or many occurrences of a parser. + */ +fun zeroOrMany(parser: Parser): Parser> { + fun oneOrMany(parser: Parser): Parser> = + parser.apply { one -> // this recursion will stop when "one" stops returning + zeroOrMany(parser).apply { zeroOrMany -> + pure(listOf(one) + zeroOrMany) + } + } + + // If "oneOrMany" fails to parse, a.k.a returns failure [] + // then to hold the "zero" part true, return a successful parsing of an empty list of T + val allVariations = oneOrMany(parser) + pure(emptyList()) + + // this recursion returns an array of all occurrences of the parsed value + // example: zeroMany(char('a')).invoke("aaa") will return: + // [ParseResult(value=[a, a, a], leftover=), ParseResult(value=[a, a], leftover=), + // ParseResult(value=[a], leftover=aa), ParseResult(value=[], leftover=aaa)] + // => we need to take only the most result with the most occurrences + // which happens to be at index 0 or first + return allVariations.first() +} + +/** + * One or many occurrences of a parser. + */ +fun oneOrMany(parser: Parser): Parser> = parser.apply { one -> + // parsed one occurrence successfully + zeroOrMany(parser).apply { zeroOrMany -> + pure(listOf(one) + zeroOrMany) + } +} + +/** + * Zero or one occurrences of a parser. This operation cannot fail. + */ +fun optional(parser: Parser): Parser = { text -> + val result = parser(text) + // if the parser fails it returns empty result + // in case of failure to satisfy "zero" return a successful null result + result.ifEmpty { listOf(ParseResult(null, text)) } +} +// endregion + +/** + * Returns a list of T values separated by something. + * This operation will never fail. In case of failure will simple return a value empty list. + */ +fun Parser.separatedBy(separator: Parser): Parser> { + fun Parser.oneOrManySepBy(separator: Parser): Parser> = this.apply { one -> + zeroOrMany( + separator.apply { + this + } + ).apply { manySeparated -> + pure(listOf(one) + manySeparated) + } + } + // the same pattern as in "zeroOrMany" + val allVariations = this.oneOrManySepBy(separator) + pure(emptyList()) + return allVariations.first() +} \ No newline at end of file diff --git a/parser/src/main/java/com/ivy/parser/common/AlphabetParser.kt b/parser/src/main/java/com/ivy/parser/common/AlphabetParser.kt new file mode 100644 index 0000000..d5265df --- /dev/null +++ b/parser/src/main/java/com/ivy/parser/common/AlphabetParser.kt @@ -0,0 +1,6 @@ +package com.ivy.parser.common + +import com.ivy.parser.Parser +import com.ivy.parser.sat + +fun letter(): Parser = sat { it.isLetter() } \ No newline at end of file diff --git a/parser/src/main/java/com/ivy/parser/common/NumberParser.kt b/parser/src/main/java/com/ivy/parser/common/NumberParser.kt new file mode 100644 index 0000000..77da510 --- /dev/null +++ b/parser/src/main/java/com/ivy/parser/common/NumberParser.kt @@ -0,0 +1,54 @@ +package com.ivy.parser.common + +import com.ivy.parser.* + +fun digit(): Parser = sat { it.isDigit() } + +/** + * Parses an integer number without a sign. + */ +fun int(): Parser = oneOrMany(digit()).apply { digits -> + val number = try { + digits.joinToString(separator = "").toInt() + } catch (e: Exception) { + Int.MAX_VALUE + } + pure(number) +} + +/** + * Parses a decimal number from as a string as double. + * + * **Supported formats:** + * - 3.14, 1024.0 _"#.#"_ + * - .5, .9 _".#"_ + * - "3." 15. _"#."_ + * - 3, 5, 8 _"#"_ + */ +fun number(): Parser { + fun oneOrMoreDigits(): Parser = oneOrMany(digit()).apply { digits -> + pure(digits.joinToString(separator = "")) + } + + return int().apply { intPart -> + // 3.14, ###.00 + char('.').apply { + oneOrMoreDigits().apply { decimalPart -> + pure("$intPart.$decimalPart".toDouble()) + } + } + } or char('.').apply { + // .5 => 0.5 + oneOrMoreDigits().apply { decimalPart -> + pure("0.$decimalPart".toDouble()) + } + } or int().apply { intPart -> + // 3. => 3.0 + char('.').apply { + pure(intPart.toDouble()) + } + } or int().apply { + // 3, 5, 13 + pure(it.toDouble()) + } +} \ No newline at end of file diff --git a/photo-frame/.gitignore b/photo-frame/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/photo-frame/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/photo-frame/build.gradle.kts b/photo-frame/build.gradle.kts new file mode 100644 index 0000000..7a5ae48 --- /dev/null +++ b/photo-frame/build.gradle.kts @@ -0,0 +1,19 @@ +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":core:domain")) + implementation(project(":core:ui")) + implementation(project(":android:file-system")) + implementation(project(":design-system")) + Testing() +} \ No newline at end of file diff --git a/photo-frame/src/main/AndroidManifest.xml b/photo-frame/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8c48026 --- /dev/null +++ b/photo-frame/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/photo-frame/src/main/java/com/ivy/photo/frame/AddFrameEvent.kt b/photo-frame/src/main/java/com/ivy/photo/frame/AddFrameEvent.kt new file mode 100644 index 0000000..d92bb38 --- /dev/null +++ b/photo-frame/src/main/java/com/ivy/photo/frame/AddFrameEvent.kt @@ -0,0 +1,8 @@ +package com.ivy.photo.frame + +import android.net.Uri + +sealed interface AddFrameEvent { + data class PhotoChanged(val photoUri: Uri) : AddFrameEvent + data class SavePhoto(val location: Uri) : AddFrameEvent +} \ No newline at end of file diff --git a/photo-frame/src/main/java/com/ivy/photo/frame/AddFrameScreen.kt b/photo-frame/src/main/java/com/ivy/photo/frame/AddFrameScreen.kt new file mode 100644 index 0000000..d052734 --- /dev/null +++ b/photo-frame/src/main/java/com/ivy/photo/frame/AddFrameScreen.kt @@ -0,0 +1,97 @@ +package com.ivy.photo.frame + +import android.graphics.Bitmap +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import coil.compose.AsyncImage +import com.ivy.core.ui.rootScreen +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.B1 +import com.ivy.design.l1_buildingBlocks.ColumnRoot +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l1_buildingBlocks.SpacerWeight +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.photo.frame.data.MessageUi + +@Composable +fun BoxScope.AddFrameScreen() { + val viewModel: AddFrameViewModel = hiltViewModel() + val state by viewModel.uiState.collectAsState() + UI(state = state, onEvent = viewModel::onEvent) +} + +@Composable +private fun UI( + state: AddFrameState, + onEvent: (AddFrameEvent) -> Unit +) { + ColumnRoot( + modifier = Modifier.padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + SpacerWeight(weight = 1f) + val rootScreen = rootScreen() + + IvyButton( + size = ButtonSize.Big, + visibility = if (state.photoWithFrame == null) Visibility.Focused else Visibility.Medium, + feeling = Feeling.Positive, + text = "Pick a photo" + ) { + rootScreen.fileChooser { + onEvent(AddFrameEvent.PhotoChanged(it)) + } + } + + if (state.message != MessageUi.None) { + SpacerVer(height = 12.dp) + when (val msg = state.message) { + is MessageUi.Error -> B1(text = msg.message, color = UI.colors.red) + is MessageUi.Loading -> B1(text = msg.message, color = UI.colors.orange) + MessageUi.None -> {} + is MessageUi.Success -> B1(text = msg.message, color = UI.colors.green) + } + SpacerVer(height = 12.dp) + } + + if (state.photoWithFrame != null) { + SpacerVer(height = 24.dp) + PhotoWithFrame(photo = state.photoWithFrame) + SpacerVer(height = 12.dp) + IvyButton( + size = ButtonSize.Big, + visibility = Visibility.Focused, + feeling = Feeling.Positive, + text = "Save photo" + ) { + rootScreen.createFile(fileName = "IvyPhotoWithFrame.png") { + onEvent(AddFrameEvent.SavePhoto(it)) + } + } + } + + SpacerWeight(weight = 1f) + } +} + +@Composable +private fun PhotoWithFrame( + photo: Bitmap +) { + AsyncImage( + modifier = Modifier.size(400.dp), + model = photo, + contentDescription = "photo with frame" + ) +} \ No newline at end of file diff --git a/photo-frame/src/main/java/com/ivy/photo/frame/AddFrameState.kt b/photo-frame/src/main/java/com/ivy/photo/frame/AddFrameState.kt new file mode 100644 index 0000000..f4f4d0f --- /dev/null +++ b/photo-frame/src/main/java/com/ivy/photo/frame/AddFrameState.kt @@ -0,0 +1,9 @@ +package com.ivy.photo.frame + +import android.graphics.Bitmap +import com.ivy.photo.frame.data.MessageUi + +data class AddFrameState( + val photoWithFrame: Bitmap?, + val message: MessageUi, +) \ No newline at end of file diff --git a/photo-frame/src/main/java/com/ivy/photo/frame/AddFrameViewModel.kt b/photo-frame/src/main/java/com/ivy/photo/frame/AddFrameViewModel.kt new file mode 100644 index 0000000..f954b01 --- /dev/null +++ b/photo-frame/src/main/java/com/ivy/photo/frame/AddFrameViewModel.kt @@ -0,0 +1,78 @@ +package com.ivy.photo.frame + +import arrow.core.Either +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.photo.frame.action.AddFrameAct +import com.ivy.photo.frame.action.SavePhotoAct +import com.ivy.photo.frame.data.MessageUi +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +@HiltViewModel +class AddFrameViewModel @Inject constructor( + private val addFrameAct: AddFrameAct, + private val savePhotoAct: SavePhotoAct, +) : SimpleFlowViewModel() { + override val initialUi = AddFrameState( + photoWithFrame = null, + message = MessageUi.None, + ) + + private val photoWithFrame = MutableStateFlow(initialUi.photoWithFrame) + private val message = MutableStateFlow(initialUi.message) + + override val uiFlow: Flow = combine( + photoWithFrame, + message + ) { photoWithFrame, message -> + AddFrameState( + photoWithFrame = photoWithFrame, + message = message, + ) + } + + + // region Event Handling + override suspend fun handleEvent(event: AddFrameEvent) { + when (event) { + is AddFrameEvent.PhotoChanged -> handlePhotoChanged(event) + is AddFrameEvent.SavePhoto -> handleSavePhoto(event) + } + } + + private suspend fun handlePhotoChanged(event: AddFrameEvent.PhotoChanged) { + message.value = MessageUi.Loading("Adding frame to photo...") + when (val res = addFrameAct(AddFrameAct.Input(photoUri = event.photoUri))) { + is Either.Left -> { + message.value = MessageUi.Error(res.value.toString()) + } + is Either.Right -> { + photoWithFrame.value = res.value + message.value = MessageUi.None + } + } + } + + private suspend fun handleSavePhoto(event: AddFrameEvent.SavePhoto) { + val photo = photoWithFrame.value ?: return + + message.value = MessageUi.Loading("Saving photo...") + when (val res = savePhotoAct( + SavePhotoAct.Input( + photo = photo, + location = event.location, + ) + )) { + is Either.Left -> { + message.value = MessageUi.Error(res.value.toString()) + } + is Either.Right -> { + message.value = MessageUi.Success("Photo saved! Location: ${event.location.path}") + } + } + } + // endregion +} \ No newline at end of file diff --git a/photo-frame/src/main/java/com/ivy/photo/frame/action/AddFrameAct.kt b/photo-frame/src/main/java/com/ivy/photo/frame/action/AddFrameAct.kt new file mode 100644 index 0000000..e9c3c60 --- /dev/null +++ b/photo-frame/src/main/java/com/ivy/photo/frame/action/AddFrameAct.kt @@ -0,0 +1,69 @@ +package com.ivy.photo.frame.action + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Matrix +import android.net.Uri +import arrow.core.Either +import arrow.core.computations.either +import com.ivy.core.domain.action.Action +import com.ivy.photo.frame.R +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class AddFrameAct @Inject constructor( + @ApplicationContext + private val appContext: Context, + private val uriToBitmapAct: UriToBitmapAct +) : Action>() { + data class Input( + val photoUri: Uri, + ) + + override suspend fun action(input: Input): Either = either { + val photo = uriToBitmapAct(input.photoUri).mapLeft(AddFrameError::LoadPhoto).bind() + val frame = loadFrameBitmap().bind() + val resizedFrame = resizeFrame(frame, photo.width, photo.height).bind() + addFrameToPhoto(photo, resizedFrame).bind() + } + + private fun loadFrameBitmap(): Either = + Either.catch(AddFrameError::LoadFrame) { + BitmapFactory.decodeResource( + appContext.resources, + R.drawable.ivyframe + ) + } + + private fun addFrameToPhoto( + photo: Bitmap, + resizedFrame: Bitmap + ): Either = + Either.catch(AddFrameError::AddFrameToPhoto) { + val photoWidth = photo.width + val photoHeight = photo.height + + val photoWithFrame = Bitmap.createBitmap(photoWidth, photoHeight, photo.config) + val canvas = Canvas(photoWithFrame) + canvas.drawBitmap(photo, Matrix(), null) // draw photo + canvas.drawBitmap(resizedFrame, 0f, 0f, null) // draw frame + + photoWithFrame + } + + private fun resizeFrame( + frame: Bitmap, photoWidth: Int, photoHeight: Int + ): Either = Either.catch(AddFrameError::ResizeFrame) { + Bitmap.createScaledBitmap(frame, photoWidth, photoHeight, true) + } +} + + +sealed interface AddFrameError { + data class LoadPhoto(val reason: Throwable) : AddFrameError + data class LoadFrame(val reason: Throwable) : AddFrameError + data class ResizeFrame(val reason: Throwable) : AddFrameError + data class AddFrameToPhoto(val reason: Throwable) : AddFrameError +} \ No newline at end of file diff --git a/photo-frame/src/main/java/com/ivy/photo/frame/action/SavePhotoAct.kt b/photo-frame/src/main/java/com/ivy/photo/frame/action/SavePhotoAct.kt new file mode 100644 index 0000000..a590b84 --- /dev/null +++ b/photo-frame/src/main/java/com/ivy/photo/frame/action/SavePhotoAct.kt @@ -0,0 +1,62 @@ +package com.ivy.photo.frame.action + +import android.content.Context +import android.graphics.Bitmap +import android.media.MediaScannerConnection +import android.net.Uri +import arrow.core.Either +import arrow.core.computations.either +import com.ivy.core.domain.action.Action +import com.ivy.file.writeToFileUnsafe +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.ByteArrayOutputStream +import javax.inject.Inject + + +class SavePhotoAct @Inject constructor( + @ApplicationContext + private val appContext: Context, +) : Action>() { + data class Input( + val photo: Bitmap, + val location: Uri + ) + + override suspend fun action(input: Input): Either = either { + val pngBytes = bitmapToPng(input.photo).bind() + writePNGtoFile(pngBytes, input.location).bind() + notifyGallery(input.location) + } + + private fun bitmapToPng(bitmap: Bitmap): Either = + Either.catch(SavePhotoError::BitmapToPng) { + val outputStream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 0, outputStream) + outputStream.toByteArray() + } + + private fun writePNGtoFile( + pngBytes: ByteArray, + location: Uri + ): Either = Either.catch(SavePhotoError::WriteToFile) { + writeToFileUnsafe(appContext, location, pngBytes) + } + + private fun notifyGallery(uri: Uri) { + try { + MediaScannerConnection.scanFile( + appContext, + arrayOf(uri.path), + arrayOf("image/png"), + null + ) + } catch (e: Exception) { + e.printStackTrace() + } + } +} + +sealed interface SavePhotoError { + data class BitmapToPng(val reason: Throwable) : SavePhotoError + data class WriteToFile(val reason: Throwable) : SavePhotoError +} \ No newline at end of file diff --git a/photo-frame/src/main/java/com/ivy/photo/frame/action/UriToBitmapAct.kt b/photo-frame/src/main/java/com/ivy/photo/frame/action/UriToBitmapAct.kt new file mode 100644 index 0000000..38a15fd --- /dev/null +++ b/photo-frame/src/main/java/com/ivy/photo/frame/action/UriToBitmapAct.kt @@ -0,0 +1,31 @@ +package com.ivy.photo.frame.action + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import arrow.core.Either +import arrow.core.computations.either +import com.ivy.core.domain.action.Action +import com.ivy.file.FDMode +import com.ivy.file.inputStream +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.InputStream +import javax.inject.Inject + + +class UriToBitmapAct @Inject constructor( + @ApplicationContext + private val appContext: Context +) : Action>() { + override suspend fun action(input: Uri): Either = either { + inputStream(appContext, input, FDMode.Read) { + decodeBitmap(it) + }.bind().bind() + } + + private fun decodeBitmap(inputStream: InputStream): Either = + Either.catch({ it }) { + BitmapFactory.decodeStream(inputStream)!! + } +} \ No newline at end of file diff --git a/photo-frame/src/main/java/com/ivy/photo/frame/data/MessageUi.kt b/photo-frame/src/main/java/com/ivy/photo/frame/data/MessageUi.kt new file mode 100644 index 0000000..a4df3e0 --- /dev/null +++ b/photo-frame/src/main/java/com/ivy/photo/frame/data/MessageUi.kt @@ -0,0 +1,11 @@ +package com.ivy.photo.frame.data + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface MessageUi { + object None : MessageUi + data class Loading(val message: String) : MessageUi + data class Error(val message: String) : MessageUi + data class Success(val message: String) : MessageUi +} \ No newline at end of file diff --git a/photo-frame/src/main/res/drawable/ivyframe.png b/photo-frame/src/main/res/drawable/ivyframe.png new file mode 100644 index 0000000..0f8717a Binary files /dev/null and b/photo-frame/src/main/res/drawable/ivyframe.png differ diff --git a/resources/.gitignore b/resources/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/resources/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/resources/README.md b/resources/README.md new file mode 100644 index 0000000..fca2879 --- /dev/null +++ b/resources/README.md @@ -0,0 +1,11 @@ +# Resources + +Contains all resources required for Ivy Wallet in one place. +- `strings` _(including translations)_ +- `drawables` + +Usage **`com.ivy.resources.R`**. + +If you want to translate Ivy Wallet in a new language - here is the place!. + +A special thanks to all contributors which have translated Ivy Wallet in their native language, helping it reach more users! 👏 diff --git a/resources/build.gradle.kts b/resources/build.gradle.kts new file mode 100644 index 0000000..1e10d44 --- /dev/null +++ b/resources/build.gradle.kts @@ -0,0 +1,14 @@ +import com.ivy.buildsrc.AppCompat +import com.ivy.buildsrc.Hilt + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + AppCompat(api = false) +} \ No newline at end of file diff --git a/resources/src/main/AndroidManifest.xml b/resources/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b61e95d --- /dev/null +++ b/resources/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/resources/src/main/res/drawable-nodpi/donate_illustration.png b/resources/src/main/res/drawable-nodpi/donate_illustration.png new file mode 100644 index 0000000..b89131b Binary files /dev/null and b/resources/src/main/res/drawable-nodpi/donate_illustration.png differ diff --git a/resources/src/main/res/drawable-nodpi/ivywallet_logo.png b/resources/src/main/res/drawable-nodpi/ivywallet_logo.png new file mode 100644 index 0000000..5c7dcd3 Binary files /dev/null and b/resources/src/main/res/drawable-nodpi/ivywallet_logo.png differ diff --git a/resources/src/main/res/drawable-nodpi/monefy_logo.png b/resources/src/main/res/drawable-nodpi/monefy_logo.png new file mode 100644 index 0000000..d8e502f Binary files /dev/null and b/resources/src/main/res/drawable-nodpi/monefy_logo.png differ diff --git a/resources/src/main/res/drawable-nodpi/moneymanager_logo.png b/resources/src/main/res/drawable-nodpi/moneymanager_logo.png new file mode 100644 index 0000000..f312b31 Binary files /dev/null and b/resources/src/main/res/drawable-nodpi/moneymanager_logo.png differ diff --git a/resources/src/main/res/drawable-nodpi/moneywallet_logo.png b/resources/src/main/res/drawable-nodpi/moneywallet_logo.png new file mode 100644 index 0000000..fbcccb9 Binary files /dev/null and b/resources/src/main/res/drawable-nodpi/moneywallet_logo.png differ diff --git a/resources/src/main/res/drawable-nodpi/spendee_logo.png b/resources/src/main/res/drawable-nodpi/spendee_logo.png new file mode 100644 index 0000000..5d480a9 Binary files /dev/null and b/resources/src/main/res/drawable-nodpi/spendee_logo.png differ diff --git a/resources/src/main/res/drawable-nodpi/wallet_by_budgetbakers_logo.png b/resources/src/main/res/drawable-nodpi/wallet_by_budgetbakers_logo.png new file mode 100644 index 0000000..1a8e14d Binary files /dev/null and b/resources/src/main/res/drawable-nodpi/wallet_by_budgetbakers_logo.png differ diff --git a/resources/src/main/res/drawable/bluecoins.png b/resources/src/main/res/drawable/bluecoins.png new file mode 100644 index 0000000..85cfcf6 Binary files /dev/null and b/resources/src/main/res/drawable/bluecoins.png differ diff --git a/resources/src/main/res/drawable/didyouknow.xml b/resources/src/main/res/drawable/didyouknow.xml new file mode 100644 index 0000000..e50ff99 --- /dev/null +++ b/resources/src/main/res/drawable/didyouknow.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/expense_shape_widget_backgroun.xml b/resources/src/main/res/drawable/expense_shape_widget_backgroun.xml new file mode 100644 index 0000000..176d687 --- /dev/null +++ b/resources/src/main/res/drawable/expense_shape_widget_backgroun.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/resources/src/main/res/drawable/financisto_logo.png b/resources/src/main/res/drawable/financisto_logo.png new file mode 100644 index 0000000..34a1d1e Binary files /dev/null and b/resources/src/main/res/drawable/financisto_logo.png differ diff --git a/resources/src/main/res/drawable/fortune_city_app_logo.png b/resources/src/main/res/drawable/fortune_city_app_logo.png new file mode 100644 index 0000000..0784dbc Binary files /dev/null and b/resources/src/main/res/drawable/fortune_city_app_logo.png differ diff --git a/resources/src/main/res/drawable/github_logo.xml b/resources/src/main/res/drawable/github_logo.xml new file mode 100644 index 0000000..9445bc3 --- /dev/null +++ b/resources/src/main/res/drawable/github_logo.xml @@ -0,0 +1,9 @@ + + + diff --git a/resources/src/main/res/drawable/home_more_menu_auto_mode.xml b/resources/src/main/res/drawable/home_more_menu_auto_mode.xml new file mode 100644 index 0000000..4c56fb8 --- /dev/null +++ b/resources/src/main/res/drawable/home_more_menu_auto_mode.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/home_more_menu_budgets.xml b/resources/src/main/res/drawable/home_more_menu_budgets.xml new file mode 100644 index 0000000..d2272ec --- /dev/null +++ b/resources/src/main/res/drawable/home_more_menu_budgets.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/home_more_menu_categories.xml b/resources/src/main/res/drawable/home_more_menu_categories.xml new file mode 100644 index 0000000..fad08f6 --- /dev/null +++ b/resources/src/main/res/drawable/home_more_menu_categories.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/home_more_menu_dark_mode.xml b/resources/src/main/res/drawable/home_more_menu_dark_mode.xml new file mode 100644 index 0000000..58cd3ab --- /dev/null +++ b/resources/src/main/res/drawable/home_more_menu_dark_mode.xml @@ -0,0 +1,13 @@ + + + diff --git a/resources/src/main/res/drawable/home_more_menu_light_mode.xml b/resources/src/main/res/drawable/home_more_menu_light_mode.xml new file mode 100644 index 0000000..392fa4f --- /dev/null +++ b/resources/src/main/res/drawable/home_more_menu_light_mode.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/home_more_menu_loans.xml b/resources/src/main/res/drawable/home_more_menu_loans.xml new file mode 100644 index 0000000..96939d1 --- /dev/null +++ b/resources/src/main/res/drawable/home_more_menu_loans.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/home_more_menu_planned_payments.xml b/resources/src/main/res/drawable/home_more_menu_planned_payments.xml new file mode 100644 index 0000000..991a385 --- /dev/null +++ b/resources/src/main/res/drawable/home_more_menu_planned_payments.xml @@ -0,0 +1,13 @@ + + + diff --git a/resources/src/main/res/drawable/home_more_menu_reports.xml b/resources/src/main/res/drawable/home_more_menu_reports.xml new file mode 100644 index 0000000..c5062eb --- /dev/null +++ b/resources/src/main/res/drawable/home_more_menu_reports.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/home_more_menu_settings.xml b/resources/src/main/res/drawable/home_more_menu_settings.xml new file mode 100644 index 0000000..4a20839 --- /dev/null +++ b/resources/src/main/res/drawable/home_more_menu_settings.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/home_more_menu_share.xml b/resources/src/main/res/drawable/home_more_menu_share.xml new file mode 100644 index 0000000..d6ef47a --- /dev/null +++ b/resources/src/main/res/drawable/home_more_menu_share.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_account_onboarding.xml b/resources/src/main/res/drawable/ic_account_onboarding.xml new file mode 100644 index 0000000..8fada89 --- /dev/null +++ b/resources/src/main/res/drawable/ic_account_onboarding.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_accounts.xml b/resources/src/main/res/drawable/ic_accounts.xml new file mode 100644 index 0000000..da3ed0e --- /dev/null +++ b/resources/src/main/res/drawable/ic_accounts.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_accounts_no_padding.xml b/resources/src/main/res/drawable/ic_accounts_no_padding.xml new file mode 100644 index 0000000..188603d --- /dev/null +++ b/resources/src/main/res/drawable/ic_accounts_no_padding.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_add_attachment.xml b/resources/src/main/res/drawable/ic_add_attachment.xml new file mode 100644 index 0000000..dc50225 --- /dev/null +++ b/resources/src/main/res/drawable/ic_add_attachment.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_add_custom_field.xml b/resources/src/main/res/drawable/ic_add_custom_field.xml new file mode 100644 index 0000000..0369f83 --- /dev/null +++ b/resources/src/main/res/drawable/ic_add_custom_field.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_add_due_date.xml b/resources/src/main/res/drawable/ic_add_due_date.xml new file mode 100644 index 0000000..059ff70 --- /dev/null +++ b/resources/src/main/res/drawable/ic_add_due_date.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_add_recurring.xml b/resources/src/main/res/drawable/ic_add_recurring.xml new file mode 100644 index 0000000..9aefd89 --- /dev/null +++ b/resources/src/main/res/drawable/ic_add_recurring.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_add_reminder.xml b/resources/src/main/res/drawable/ic_add_reminder.xml new file mode 100644 index 0000000..14d43f7 --- /dev/null +++ b/resources/src/main/res/drawable/ic_add_reminder.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_add_timetracking.xml b/resources/src/main/res/drawable/ic_add_timetracking.xml new file mode 100644 index 0000000..9a92465 --- /dev/null +++ b/resources/src/main/res/drawable/ic_add_timetracking.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_agreed.xml b/resources/src/main/res/drawable/ic_agreed.xml new file mode 100644 index 0000000..4cab378 --- /dev/null +++ b/resources/src/main/res/drawable/ic_agreed.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_alarm.xml b/resources/src/main/res/drawable/ic_alarm.xml new file mode 100644 index 0000000..37c8c03 --- /dev/null +++ b/resources/src/main/res/drawable/ic_alarm.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_archive.xml b/resources/src/main/res/drawable/ic_archive.xml new file mode 100644 index 0000000..0ff695e --- /dev/null +++ b/resources/src/main/res/drawable/ic_archive.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_arrow_right.xml b/resources/src/main/res/drawable/ic_arrow_right.xml new file mode 100644 index 0000000..b982df9 --- /dev/null +++ b/resources/src/main/res/drawable/ic_arrow_right.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_attachment.xml b/resources/src/main/res/drawable/ic_attachment.xml new file mode 100644 index 0000000..1bd4efe --- /dev/null +++ b/resources/src/main/res/drawable/ic_attachment.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_back.xml b/resources/src/main/res/drawable/ic_back.xml new file mode 100644 index 0000000..435abc5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_back.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_back_android.xml b/resources/src/main/res/drawable/ic_back_android.xml new file mode 100644 index 0000000..a8129ac --- /dev/null +++ b/resources/src/main/res/drawable/ic_back_android.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_backspace.xml b/resources/src/main/res/drawable/ic_backspace.xml new file mode 100644 index 0000000..51dfb85 --- /dev/null +++ b/resources/src/main/res/drawable/ic_backspace.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_baseline_all_inclusive_24.xml b/resources/src/main/res/drawable/ic_baseline_all_inclusive_24.xml new file mode 100644 index 0000000..0d0cf9f --- /dev/null +++ b/resources/src/main/res/drawable/ic_baseline_all_inclusive_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_budget_s.xml b/resources/src/main/res/drawable/ic_budget_s.xml new file mode 100644 index 0000000..3d9db3d --- /dev/null +++ b/resources/src/main/res/drawable/ic_budget_s.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_budget_xl.xml b/resources/src/main/res/drawable/ic_budget_xl.xml new file mode 100644 index 0000000..bcedb96 --- /dev/null +++ b/resources/src/main/res/drawable/ic_budget_xl.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_budget_xs.xml b/resources/src/main/res/drawable/ic_budget_xs.xml new file mode 100644 index 0000000..c7373c5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_budget_xs.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_buffer_exceeded.xml b/resources/src/main/res/drawable/ic_buffer_exceeded.xml new file mode 100644 index 0000000..3ebc4c7 --- /dev/null +++ b/resources/src/main/res/drawable/ic_buffer_exceeded.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_buffer_ok.xml b/resources/src/main/res/drawable/ic_buffer_ok.xml new file mode 100644 index 0000000..4cab378 --- /dev/null +++ b/resources/src/main/res/drawable/ic_buffer_ok.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_bulb.xml b/resources/src/main/res/drawable/ic_bulb.xml new file mode 100644 index 0000000..e50f7a5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_bulb.xml @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_calendar.xml b/resources/src/main/res/drawable/ic_calendar.xml new file mode 100644 index 0000000..04ddd37 --- /dev/null +++ b/resources/src/main/res/drawable/ic_calendar.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_categories.xml b/resources/src/main/res/drawable/ic_categories.xml new file mode 100644 index 0000000..4188002 --- /dev/null +++ b/resources/src/main/res/drawable/ic_categories.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_categories_no_padding.xml b/resources/src/main/res/drawable/ic_categories_no_padding.xml new file mode 100644 index 0000000..a04d5cb --- /dev/null +++ b/resources/src/main/res/drawable/ic_categories_no_padding.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_category_edit.xml b/resources/src/main/res/drawable/ic_category_edit.xml new file mode 100644 index 0000000..15f23af --- /dev/null +++ b/resources/src/main/res/drawable/ic_category_edit.xml @@ -0,0 +1,9 @@ + + + diff --git a/resources/src/main/res/drawable/ic_check.xml b/resources/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000..c5f01c5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_check.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_checkbox_checked.xml b/resources/src/main/res/drawable/ic_checkbox_checked.xml new file mode 100644 index 0000000..28d4ae8 --- /dev/null +++ b/resources/src/main/res/drawable/ic_checkbox_checked.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_checkbox_unchecked.xml b/resources/src/main/res/drawable/ic_checkbox_unchecked.xml new file mode 100644 index 0000000..7841002 --- /dev/null +++ b/resources/src/main/res/drawable/ic_checkbox_unchecked.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_checklist.xml b/resources/src/main/res/drawable/ic_checklist.xml new file mode 100644 index 0000000..51a0268 --- /dev/null +++ b/resources/src/main/res/drawable/ic_checklist.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_checklist_add.xml b/resources/src/main/res/drawable/ic_checklist_add.xml new file mode 100644 index 0000000..b7c0358 --- /dev/null +++ b/resources/src/main/res/drawable/ic_checklist_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/resources/src/main/res/drawable/ic_clock.xml b/resources/src/main/res/drawable/ic_clock.xml new file mode 100644 index 0000000..e6559ee --- /dev/null +++ b/resources/src/main/res/drawable/ic_clock.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_currency.xml b/resources/src/main/res/drawable/ic_currency.xml new file mode 100644 index 0000000..4aade53 --- /dev/null +++ b/resources/src/main/res/drawable/ic_currency.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_account_l.xml b/resources/src/main/res/drawable/ic_custom_account_l.xml new file mode 100644 index 0000000..e1cd3a5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_account_l.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_account_m.xml b/resources/src/main/res/drawable/ic_custom_account_m.xml new file mode 100644 index 0000000..141beaa --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_account_m.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_account_s.xml b/resources/src/main/res/drawable/ic_custom_account_s.xml new file mode 100644 index 0000000..f577a8d --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_account_s.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_ada_l.xml b/resources/src/main/res/drawable/ic_custom_ada_l.xml new file mode 100644 index 0000000..b30302a --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_ada_l.xml @@ -0,0 +1,10 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_ada_m.xml b/resources/src/main/res/drawable/ic_custom_ada_m.xml new file mode 100644 index 0000000..4582769 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_ada_m.xml @@ -0,0 +1,10 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_ada_s.xml b/resources/src/main/res/drawable/ic_custom_ada_s.xml new file mode 100644 index 0000000..2d68916 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_ada_s.xml @@ -0,0 +1,10 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_atom_l.xml b/resources/src/main/res/drawable/ic_custom_atom_l.xml new file mode 100644 index 0000000..ba98cc6 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_atom_l.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_atom_m.xml b/resources/src/main/res/drawable/ic_custom_atom_m.xml new file mode 100644 index 0000000..816bf32 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_atom_m.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_atom_s.xml b/resources/src/main/res/drawable/ic_custom_atom_s.xml new file mode 100644 index 0000000..d30f0d5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_atom_s.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_bank_l.xml b/resources/src/main/res/drawable/ic_custom_bank_l.xml new file mode 100644 index 0000000..4921c83 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_bank_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_bank_m.xml b/resources/src/main/res/drawable/ic_custom_bank_m.xml new file mode 100644 index 0000000..3ce5809 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_bank_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_bank_s.xml b/resources/src/main/res/drawable/ic_custom_bank_s.xml new file mode 100644 index 0000000..8de7c30 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_bank_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_bills_l.xml b/resources/src/main/res/drawable/ic_custom_bills_l.xml new file mode 100644 index 0000000..ff1215e --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_bills_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_bills_m.xml b/resources/src/main/res/drawable/ic_custom_bills_m.xml new file mode 100644 index 0000000..0d659da --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_bills_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_bills_s.xml b/resources/src/main/res/drawable/ic_custom_bills_s.xml new file mode 100644 index 0000000..baba427 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_bills_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_birthday_l.xml b/resources/src/main/res/drawable/ic_custom_birthday_l.xml new file mode 100644 index 0000000..7d91555 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_birthday_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_birthday_m.xml b/resources/src/main/res/drawable/ic_custom_birthday_m.xml new file mode 100644 index 0000000..36f00b0 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_birthday_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_birthday_s.xml b/resources/src/main/res/drawable/ic_custom_birthday_s.xml new file mode 100644 index 0000000..d8934f7 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_birthday_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_btc_l.xml b/resources/src/main/res/drawable/ic_custom_btc_l.xml new file mode 100644 index 0000000..a99caf4 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_btc_l.xml @@ -0,0 +1,10 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_btc_m.xml b/resources/src/main/res/drawable/ic_custom_btc_m.xml new file mode 100644 index 0000000..f71639e --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_btc_m.xml @@ -0,0 +1,10 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_btc_s.xml b/resources/src/main/res/drawable/ic_custom_btc_s.xml new file mode 100644 index 0000000..3e12d9e --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_btc_s.xml @@ -0,0 +1,10 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_calculator_l.xml b/resources/src/main/res/drawable/ic_custom_calculator_l.xml new file mode 100644 index 0000000..22a1154 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_calculator_l.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_calculator_m.xml b/resources/src/main/res/drawable/ic_custom_calculator_m.xml new file mode 100644 index 0000000..535101d --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_calculator_m.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_calculator_s.xml b/resources/src/main/res/drawable/ic_custom_calculator_s.xml new file mode 100644 index 0000000..4b987e6 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_calculator_s.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_calendar_l.xml b/resources/src/main/res/drawable/ic_custom_calendar_l.xml new file mode 100644 index 0000000..6a5e747 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_calendar_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_calendar_m.xml b/resources/src/main/res/drawable/ic_custom_calendar_m.xml new file mode 100644 index 0000000..3581592 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_calendar_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_calendar_s.xml b/resources/src/main/res/drawable/ic_custom_calendar_s.xml new file mode 100644 index 0000000..2bddaa6 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_calendar_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_camera_l.xml b/resources/src/main/res/drawable/ic_custom_camera_l.xml new file mode 100644 index 0000000..4b0536f --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_camera_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_camera_m.xml b/resources/src/main/res/drawable/ic_custom_camera_m.xml new file mode 100644 index 0000000..a9d249c --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_camera_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_camera_s.xml b/resources/src/main/res/drawable/ic_custom_camera_s.xml new file mode 100644 index 0000000..f3802cf --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_camera_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_cash_l.xml b/resources/src/main/res/drawable/ic_custom_cash_l.xml new file mode 100644 index 0000000..6ce5931 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_cash_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_cash_m.xml b/resources/src/main/res/drawable/ic_custom_cash_m.xml new file mode 100644 index 0000000..909420f --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_cash_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_cash_s.xml b/resources/src/main/res/drawable/ic_custom_cash_s.xml new file mode 100644 index 0000000..4f9275f --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_cash_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_category_l.xml b/resources/src/main/res/drawable/ic_custom_category_l.xml new file mode 100644 index 0000000..100fcc5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_category_l.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_category_m.xml b/resources/src/main/res/drawable/ic_custom_category_m.xml new file mode 100644 index 0000000..dbd3571 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_category_m.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_category_s.xml b/resources/src/main/res/drawable/ic_custom_category_s.xml new file mode 100644 index 0000000..55ab0df --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_category_s.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_chemistry_l.xml b/resources/src/main/res/drawable/ic_custom_chemistry_l.xml new file mode 100644 index 0000000..81ece70 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_chemistry_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_chemistry_m.xml b/resources/src/main/res/drawable/ic_custom_chemistry_m.xml new file mode 100644 index 0000000..3d721ee --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_chemistry_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_chemistry_s.xml b/resources/src/main/res/drawable/ic_custom_chemistry_s.xml new file mode 100644 index 0000000..8232bfa --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_chemistry_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_clothes2_l.xml b/resources/src/main/res/drawable/ic_custom_clothes2_l.xml new file mode 100644 index 0000000..5b71799 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_clothes2_l.xml @@ -0,0 +1,16 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_clothes2_m.xml b/resources/src/main/res/drawable/ic_custom_clothes2_m.xml new file mode 100644 index 0000000..b32797a --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_clothes2_m.xml @@ -0,0 +1,16 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_clothes2_s.xml b/resources/src/main/res/drawable/ic_custom_clothes2_s.xml new file mode 100644 index 0000000..92803ba --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_clothes2_s.xml @@ -0,0 +1,16 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_clothes_l.xml b/resources/src/main/res/drawable/ic_custom_clothes_l.xml new file mode 100644 index 0000000..bc733c0 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_clothes_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_clothes_m.xml b/resources/src/main/res/drawable/ic_custom_clothes_m.xml new file mode 100644 index 0000000..1f5b6ea --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_clothes_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_clothes_s.xml b/resources/src/main/res/drawable/ic_custom_clothes_s.xml new file mode 100644 index 0000000..d1841c0 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_clothes_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_coffee_l.xml b/resources/src/main/res/drawable/ic_custom_coffee_l.xml new file mode 100644 index 0000000..c13f7d3 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_coffee_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_coffee_m.xml b/resources/src/main/res/drawable/ic_custom_coffee_m.xml new file mode 100644 index 0000000..b4b4b4a --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_coffee_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_coffee_s.xml b/resources/src/main/res/drawable/ic_custom_coffee_s.xml new file mode 100644 index 0000000..85693c8 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_coffee_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_connect_l.xml b/resources/src/main/res/drawable/ic_custom_connect_l.xml new file mode 100644 index 0000000..d9e790a --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_connect_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_connect_m.xml b/resources/src/main/res/drawable/ic_custom_connect_m.xml new file mode 100644 index 0000000..f395b28 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_connect_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_connect_s.xml b/resources/src/main/res/drawable/ic_custom_connect_s.xml new file mode 100644 index 0000000..2355d1c --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_connect_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_crown_l.xml b/resources/src/main/res/drawable/ic_custom_crown_l.xml new file mode 100644 index 0000000..2a7100e --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_crown_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_crown_m.xml b/resources/src/main/res/drawable/ic_custom_crown_m.xml new file mode 100644 index 0000000..54ab3cc --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_crown_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_crown_s.xml b/resources/src/main/res/drawable/ic_custom_crown_s.xml new file mode 100644 index 0000000..b06bc17 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_crown_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_diamond_l.xml b/resources/src/main/res/drawable/ic_custom_diamond_l.xml new file mode 100644 index 0000000..f5ee9d8 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_diamond_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_diamond_m.xml b/resources/src/main/res/drawable/ic_custom_diamond_m.xml new file mode 100644 index 0000000..d133f14 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_diamond_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_diamond_s.xml b/resources/src/main/res/drawable/ic_custom_diamond_s.xml new file mode 100644 index 0000000..b93e271 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_diamond_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_dna_l.xml b/resources/src/main/res/drawable/ic_custom_dna_l.xml new file mode 100644 index 0000000..1738bb0 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_dna_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_dna_m.xml b/resources/src/main/res/drawable/ic_custom_dna_m.xml new file mode 100644 index 0000000..c695c8e --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_dna_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_dna_s.xml b/resources/src/main/res/drawable/ic_custom_dna_s.xml new file mode 100644 index 0000000..f81c19b --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_dna_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_doctor_l.xml b/resources/src/main/res/drawable/ic_custom_doctor_l.xml new file mode 100644 index 0000000..6825fb2 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_doctor_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_doctor_m.xml b/resources/src/main/res/drawable/ic_custom_doctor_m.xml new file mode 100644 index 0000000..5e94c21 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_doctor_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_doctor_s.xml b/resources/src/main/res/drawable/ic_custom_doctor_s.xml new file mode 100644 index 0000000..88e17bd --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_doctor_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_document_l.xml b/resources/src/main/res/drawable/ic_custom_document_l.xml new file mode 100644 index 0000000..f8fb991 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_document_l.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_document_m.xml b/resources/src/main/res/drawable/ic_custom_document_m.xml new file mode 100644 index 0000000..157d9bc --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_document_m.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_document_s.xml b/resources/src/main/res/drawable/ic_custom_document_s.xml new file mode 100644 index 0000000..d6e83ee --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_document_s.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_doge_l.xml b/resources/src/main/res/drawable/ic_custom_doge_l.xml new file mode 100644 index 0000000..5775c71 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_doge_l.xml @@ -0,0 +1,11 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_doge_m.xml b/resources/src/main/res/drawable/ic_custom_doge_m.xml new file mode 100644 index 0000000..b38be81 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_doge_m.xml @@ -0,0 +1,11 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_doge_s.xml b/resources/src/main/res/drawable/ic_custom_doge_s.xml new file mode 100644 index 0000000..1f3af4b --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_doge_s.xml @@ -0,0 +1,11 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_drink_l.xml b/resources/src/main/res/drawable/ic_custom_drink_l.xml new file mode 100644 index 0000000..7e38fe8 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_drink_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_drink_m.xml b/resources/src/main/res/drawable/ic_custom_drink_m.xml new file mode 100644 index 0000000..89bba53 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_drink_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_drink_s.xml b/resources/src/main/res/drawable/ic_custom_drink_s.xml new file mode 100644 index 0000000..ceb2592 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_drink_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_education_l.xml b/resources/src/main/res/drawable/ic_custom_education_l.xml new file mode 100644 index 0000000..e9770c0 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_education_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_education_m.xml b/resources/src/main/res/drawable/ic_custom_education_m.xml new file mode 100644 index 0000000..afaa5ec --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_education_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_education_s.xml b/resources/src/main/res/drawable/ic_custom_education_s.xml new file mode 100644 index 0000000..75b89da --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_education_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_eth_l.xml b/resources/src/main/res/drawable/ic_custom_eth_l.xml new file mode 100644 index 0000000..7ae7113 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_eth_l.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_eth_m.xml b/resources/src/main/res/drawable/ic_custom_eth_m.xml new file mode 100644 index 0000000..cd52904 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_eth_m.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_eth_s.xml b/resources/src/main/res/drawable/ic_custom_eth_s.xml new file mode 100644 index 0000000..35eee75 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_eth_s.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_family_l.xml b/resources/src/main/res/drawable/ic_custom_family_l.xml new file mode 100644 index 0000000..89cce00 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_family_l.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_family_m.xml b/resources/src/main/res/drawable/ic_custom_family_m.xml new file mode 100644 index 0000000..ff5f12d --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_family_m.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_family_s.xml b/resources/src/main/res/drawable/ic_custom_family_s.xml new file mode 100644 index 0000000..412bcca --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_family_s.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_farmacy_l.xml b/resources/src/main/res/drawable/ic_custom_farmacy_l.xml new file mode 100644 index 0000000..00dd974 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_farmacy_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_farmacy_m.xml b/resources/src/main/res/drawable/ic_custom_farmacy_m.xml new file mode 100644 index 0000000..ff5a966 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_farmacy_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_farmacy_s.xml b/resources/src/main/res/drawable/ic_custom_farmacy_s.xml new file mode 100644 index 0000000..fa1be46 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_farmacy_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_fingerprint_l.xml b/resources/src/main/res/drawable/ic_custom_fingerprint_l.xml new file mode 100644 index 0000000..04f4bc8 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_fingerprint_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_fingerprint_m.xml b/resources/src/main/res/drawable/ic_custom_fingerprint_m.xml new file mode 100644 index 0000000..b18d087 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_fingerprint_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_fingerprint_s.xml b/resources/src/main/res/drawable/ic_custom_fingerprint_s.xml new file mode 100644 index 0000000..4ce9db5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_fingerprint_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_fishfood_l.xml b/resources/src/main/res/drawable/ic_custom_fishfood_l.xml new file mode 100644 index 0000000..ffaa9b5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_fishfood_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_fishfood_m.xml b/resources/src/main/res/drawable/ic_custom_fishfood_m.xml new file mode 100644 index 0000000..6acbda0 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_fishfood_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_fishfood_s.xml b/resources/src/main/res/drawable/ic_custom_fishfood_s.xml new file mode 100644 index 0000000..b33c2b4 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_fishfood_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_fitness_l.xml b/resources/src/main/res/drawable/ic_custom_fitness_l.xml new file mode 100644 index 0000000..ee2da00 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_fitness_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_fitness_m.xml b/resources/src/main/res/drawable/ic_custom_fitness_m.xml new file mode 100644 index 0000000..5933881 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_fitness_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_fitness_s.xml b/resources/src/main/res/drawable/ic_custom_fitness_s.xml new file mode 100644 index 0000000..7323448 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_fitness_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_food2_l.xml b/resources/src/main/res/drawable/ic_custom_food2_l.xml new file mode 100644 index 0000000..af1b0ff --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_food2_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_food2_m.xml b/resources/src/main/res/drawable/ic_custom_food2_m.xml new file mode 100644 index 0000000..bdaadf3 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_food2_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_food2_s.xml b/resources/src/main/res/drawable/ic_custom_food2_s.xml new file mode 100644 index 0000000..c93142e --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_food2_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_fooddrink_l.xml b/resources/src/main/res/drawable/ic_custom_fooddrink_l.xml new file mode 100644 index 0000000..71067aa --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_fooddrink_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_fooddrink_m.xml b/resources/src/main/res/drawable/ic_custom_fooddrink_m.xml new file mode 100644 index 0000000..e6a159c --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_fooddrink_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_fooddrink_s.xml b/resources/src/main/res/drawable/ic_custom_fooddrink_s.xml new file mode 100644 index 0000000..372a184 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_fooddrink_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_furniture_l.xml b/resources/src/main/res/drawable/ic_custom_furniture_l.xml new file mode 100644 index 0000000..4f53669 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_furniture_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_furniture_m.xml b/resources/src/main/res/drawable/ic_custom_furniture_m.xml new file mode 100644 index 0000000..bfc7c7d --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_furniture_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_furniture_s.xml b/resources/src/main/res/drawable/ic_custom_furniture_s.xml new file mode 100644 index 0000000..1f7467f --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_furniture_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_gambling_l.xml b/resources/src/main/res/drawable/ic_custom_gambling_l.xml new file mode 100644 index 0000000..0ec1aed --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_gambling_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_gambling_m.xml b/resources/src/main/res/drawable/ic_custom_gambling_m.xml new file mode 100644 index 0000000..bedc68a --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_gambling_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_gambling_s.xml b/resources/src/main/res/drawable/ic_custom_gambling_s.xml new file mode 100644 index 0000000..5ed5491 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_gambling_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_game_l.xml b/resources/src/main/res/drawable/ic_custom_game_l.xml new file mode 100644 index 0000000..d3097ce --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_game_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_game_m.xml b/resources/src/main/res/drawable/ic_custom_game_m.xml new file mode 100644 index 0000000..f0c5fdf --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_game_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_game_s.xml b/resources/src/main/res/drawable/ic_custom_game_s.xml new file mode 100644 index 0000000..486b0a1 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_game_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_gears_l.xml b/resources/src/main/res/drawable/ic_custom_gears_l.xml new file mode 100644 index 0000000..9fa912a --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_gears_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_gears_m.xml b/resources/src/main/res/drawable/ic_custom_gears_m.xml new file mode 100644 index 0000000..9abb2a7 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_gears_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_gears_s.xml b/resources/src/main/res/drawable/ic_custom_gears_s.xml new file mode 100644 index 0000000..636b56d --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_gears_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_gift_l.xml b/resources/src/main/res/drawable/ic_custom_gift_l.xml new file mode 100644 index 0000000..e4159ce --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_gift_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_gift_m.xml b/resources/src/main/res/drawable/ic_custom_gift_m.xml new file mode 100644 index 0000000..92e5c2a --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_gift_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_gift_s.xml b/resources/src/main/res/drawable/ic_custom_gift_s.xml new file mode 100644 index 0000000..d3cb92f --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_gift_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_groceries_l.xml b/resources/src/main/res/drawable/ic_custom_groceries_l.xml new file mode 100644 index 0000000..92d2a0a --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_groceries_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_groceries_m.xml b/resources/src/main/res/drawable/ic_custom_groceries_m.xml new file mode 100644 index 0000000..a317151 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_groceries_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_groceries_s.xml b/resources/src/main/res/drawable/ic_custom_groceries_s.xml new file mode 100644 index 0000000..5046d58 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_groceries_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_hairdresser_l.xml b/resources/src/main/res/drawable/ic_custom_hairdresser_l.xml new file mode 100644 index 0000000..4a5589c --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_hairdresser_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_hairdresser_m.xml b/resources/src/main/res/drawable/ic_custom_hairdresser_m.xml new file mode 100644 index 0000000..85d3ee5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_hairdresser_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_hairdresser_s.xml b/resources/src/main/res/drawable/ic_custom_hairdresser_s.xml new file mode 100644 index 0000000..8460253 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_hairdresser_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_health_l.xml b/resources/src/main/res/drawable/ic_custom_health_l.xml new file mode 100644 index 0000000..79f2110 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_health_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_health_m.xml b/resources/src/main/res/drawable/ic_custom_health_m.xml new file mode 100644 index 0000000..544c1b8 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_health_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_health_s.xml b/resources/src/main/res/drawable/ic_custom_health_s.xml new file mode 100644 index 0000000..b58b349 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_health_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_hike_l.xml b/resources/src/main/res/drawable/ic_custom_hike_l.xml new file mode 100644 index 0000000..83b772d --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_hike_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_hike_m.xml b/resources/src/main/res/drawable/ic_custom_hike_m.xml new file mode 100644 index 0000000..ed1e78b --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_hike_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_hike_s.xml b/resources/src/main/res/drawable/ic_custom_hike_s.xml new file mode 100644 index 0000000..6c8a7cc --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_hike_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_house_l.xml b/resources/src/main/res/drawable/ic_custom_house_l.xml new file mode 100644 index 0000000..08b2ce4 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_house_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_house_m.xml b/resources/src/main/res/drawable/ic_custom_house_m.xml new file mode 100644 index 0000000..53d7a4a --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_house_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_house_s.xml b/resources/src/main/res/drawable/ic_custom_house_s.xml new file mode 100644 index 0000000..1efa434 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_house_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_insurance_l.xml b/resources/src/main/res/drawable/ic_custom_insurance_l.xml new file mode 100644 index 0000000..0a64cc2 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_insurance_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_insurance_m.xml b/resources/src/main/res/drawable/ic_custom_insurance_m.xml new file mode 100644 index 0000000..77d9eb0 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_insurance_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_insurance_s.xml b/resources/src/main/res/drawable/ic_custom_insurance_s.xml new file mode 100644 index 0000000..59cc9c0 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_insurance_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_label_l.xml b/resources/src/main/res/drawable/ic_custom_label_l.xml new file mode 100644 index 0000000..00a5e18 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_label_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_label_m.xml b/resources/src/main/res/drawable/ic_custom_label_m.xml new file mode 100644 index 0000000..215b2a4 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_label_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_label_s.xml b/resources/src/main/res/drawable/ic_custom_label_s.xml new file mode 100644 index 0000000..965fbac --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_label_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_leaf_l.xml b/resources/src/main/res/drawable/ic_custom_leaf_l.xml new file mode 100644 index 0000000..063a978 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_leaf_l.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_leaf_m.xml b/resources/src/main/res/drawable/ic_custom_leaf_m.xml new file mode 100644 index 0000000..b93ecb6 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_leaf_m.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_leaf_s.xml b/resources/src/main/res/drawable/ic_custom_leaf_s.xml new file mode 100644 index 0000000..eef2722 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_leaf_s.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_loan_l.xml b/resources/src/main/res/drawable/ic_custom_loan_l.xml new file mode 100644 index 0000000..1c06f7b --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_loan_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_loan_m.xml b/resources/src/main/res/drawable/ic_custom_loan_m.xml new file mode 100644 index 0000000..1a7adc3 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_loan_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_loan_s.xml b/resources/src/main/res/drawable/ic_custom_loan_s.xml new file mode 100644 index 0000000..75ef8df --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_loan_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_location_l.xml b/resources/src/main/res/drawable/ic_custom_location_l.xml new file mode 100644 index 0000000..487a0db --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_location_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_location_m.xml b/resources/src/main/res/drawable/ic_custom_location_m.xml new file mode 100644 index 0000000..b902cbe --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_location_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_location_s.xml b/resources/src/main/res/drawable/ic_custom_location_s.xml new file mode 100644 index 0000000..d42929d --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_location_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_makeup_l.xml b/resources/src/main/res/drawable/ic_custom_makeup_l.xml new file mode 100644 index 0000000..db84d85 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_makeup_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_makeup_m.xml b/resources/src/main/res/drawable/ic_custom_makeup_m.xml new file mode 100644 index 0000000..a748340 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_makeup_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_makeup_s.xml b/resources/src/main/res/drawable/ic_custom_makeup_s.xml new file mode 100644 index 0000000..bcc5360 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_makeup_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_music_l.xml b/resources/src/main/res/drawable/ic_custom_music_l.xml new file mode 100644 index 0000000..c188d10 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_music_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_music_m.xml b/resources/src/main/res/drawable/ic_custom_music_m.xml new file mode 100644 index 0000000..ecde7a0 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_music_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_music_s.xml b/resources/src/main/res/drawable/ic_custom_music_s.xml new file mode 100644 index 0000000..5b20839 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_music_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_notice_l.xml b/resources/src/main/res/drawable/ic_custom_notice_l.xml new file mode 100644 index 0000000..4ba42bb --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_notice_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_notice_m.xml b/resources/src/main/res/drawable/ic_custom_notice_m.xml new file mode 100644 index 0000000..c41f167 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_notice_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_notice_s.xml b/resources/src/main/res/drawable/ic_custom_notice_s.xml new file mode 100644 index 0000000..dc4c452 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_notice_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_orderfood2_l.xml b/resources/src/main/res/drawable/ic_custom_orderfood2_l.xml new file mode 100644 index 0000000..9ad0221 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_orderfood2_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_orderfood2_m.xml b/resources/src/main/res/drawable/ic_custom_orderfood2_m.xml new file mode 100644 index 0000000..46234f8 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_orderfood2_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_orderfood2_s.xml b/resources/src/main/res/drawable/ic_custom_orderfood2_s.xml new file mode 100644 index 0000000..7ecd796 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_orderfood2_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_orderfood_l.xml b/resources/src/main/res/drawable/ic_custom_orderfood_l.xml new file mode 100644 index 0000000..f142b2c --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_orderfood_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_orderfood_m.xml b/resources/src/main/res/drawable/ic_custom_orderfood_m.xml new file mode 100644 index 0000000..8333b9e --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_orderfood_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_orderfood_s.xml b/resources/src/main/res/drawable/ic_custom_orderfood_s.xml new file mode 100644 index 0000000..c1cf61f --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_orderfood_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_palette_l.xml b/resources/src/main/res/drawable/ic_custom_palette_l.xml new file mode 100644 index 0000000..2beef69 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_palette_l.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_palette_m.xml b/resources/src/main/res/drawable/ic_custom_palette_m.xml new file mode 100644 index 0000000..78a06a3 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_palette_m.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_palette_s.xml b/resources/src/main/res/drawable/ic_custom_palette_s.xml new file mode 100644 index 0000000..cbecb7d --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_palette_s.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_people_l.xml b/resources/src/main/res/drawable/ic_custom_people_l.xml new file mode 100644 index 0000000..be634fb --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_people_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_people_m.xml b/resources/src/main/res/drawable/ic_custom_people_m.xml new file mode 100644 index 0000000..61f2210 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_people_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_people_s.xml b/resources/src/main/res/drawable/ic_custom_people_s.xml new file mode 100644 index 0000000..4c436fc --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_people_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_pet_l.xml b/resources/src/main/res/drawable/ic_custom_pet_l.xml new file mode 100644 index 0000000..8fd9349 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_pet_l.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_pet_m.xml b/resources/src/main/res/drawable/ic_custom_pet_m.xml new file mode 100644 index 0000000..6c6b730 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_pet_m.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_pet_s.xml b/resources/src/main/res/drawable/ic_custom_pet_s.xml new file mode 100644 index 0000000..985b12b --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_pet_s.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_plant_l.xml b/resources/src/main/res/drawable/ic_custom_plant_l.xml new file mode 100644 index 0000000..b6c82db --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_plant_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_plant_m.xml b/resources/src/main/res/drawable/ic_custom_plant_m.xml new file mode 100644 index 0000000..74412e9 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_plant_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_plant_s.xml b/resources/src/main/res/drawable/ic_custom_plant_s.xml new file mode 100644 index 0000000..135cd27 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_plant_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_programming_l.xml b/resources/src/main/res/drawable/ic_custom_programming_l.xml new file mode 100644 index 0000000..9abdf20 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_programming_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_programming_m.xml b/resources/src/main/res/drawable/ic_custom_programming_m.xml new file mode 100644 index 0000000..2c9f4fc --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_programming_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_programming_s.xml b/resources/src/main/res/drawable/ic_custom_programming_s.xml new file mode 100644 index 0000000..bc77d0a --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_programming_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_relationship_l.xml b/resources/src/main/res/drawable/ic_custom_relationship_l.xml new file mode 100644 index 0000000..086508a --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_relationship_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_relationship_m.xml b/resources/src/main/res/drawable/ic_custom_relationship_m.xml new file mode 100644 index 0000000..284aa7b --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_relationship_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_relationship_s.xml b/resources/src/main/res/drawable/ic_custom_relationship_s.xml new file mode 100644 index 0000000..d9226e0 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_relationship_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_restaurant_l.xml b/resources/src/main/res/drawable/ic_custom_restaurant_l.xml new file mode 100644 index 0000000..4d61278 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_restaurant_l.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_restaurant_m.xml b/resources/src/main/res/drawable/ic_custom_restaurant_m.xml new file mode 100644 index 0000000..c9ad1de --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_restaurant_m.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_restaurant_s.xml b/resources/src/main/res/drawable/ic_custom_restaurant_s.xml new file mode 100644 index 0000000..936b51c --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_restaurant_s.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_revolut_l.xml b/resources/src/main/res/drawable/ic_custom_revolut_l.xml new file mode 100644 index 0000000..7e2112f --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_revolut_l.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_revolut_m.xml b/resources/src/main/res/drawable/ic_custom_revolut_m.xml new file mode 100644 index 0000000..470ef25 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_revolut_m.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_revolut_s.xml b/resources/src/main/res/drawable/ic_custom_revolut_s.xml new file mode 100644 index 0000000..b9fab38 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_revolut_s.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_rocket_l.xml b/resources/src/main/res/drawable/ic_custom_rocket_l.xml new file mode 100644 index 0000000..9692b07 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_rocket_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_rocket_m.xml b/resources/src/main/res/drawable/ic_custom_rocket_m.xml new file mode 100644 index 0000000..a8a7f5a --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_rocket_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_rocket_s.xml b/resources/src/main/res/drawable/ic_custom_rocket_s.xml new file mode 100644 index 0000000..301ed3f --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_rocket_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_safe_l.xml b/resources/src/main/res/drawable/ic_custom_safe_l.xml new file mode 100644 index 0000000..4eb0ca4 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_safe_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_safe_m.xml b/resources/src/main/res/drawable/ic_custom_safe_m.xml new file mode 100644 index 0000000..d1a9142 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_safe_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_safe_s.xml b/resources/src/main/res/drawable/ic_custom_safe_s.xml new file mode 100644 index 0000000..267cb5f --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_safe_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_sail_l.xml b/resources/src/main/res/drawable/ic_custom_sail_l.xml new file mode 100644 index 0000000..c4a0923 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_sail_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_sail_m.xml b/resources/src/main/res/drawable/ic_custom_sail_m.xml new file mode 100644 index 0000000..149e683 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_sail_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_sail_s.xml b/resources/src/main/res/drawable/ic_custom_sail_s.xml new file mode 100644 index 0000000..3f246ba --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_sail_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_selfdevelopment_l.xml b/resources/src/main/res/drawable/ic_custom_selfdevelopment_l.xml new file mode 100644 index 0000000..f8ea435 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_selfdevelopment_l.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_selfdevelopment_m.xml b/resources/src/main/res/drawable/ic_custom_selfdevelopment_m.xml new file mode 100644 index 0000000..8d7142e --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_selfdevelopment_m.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_selfdevelopment_s.xml b/resources/src/main/res/drawable/ic_custom_selfdevelopment_s.xml new file mode 100644 index 0000000..0b0c824 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_selfdevelopment_s.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_server_l.xml b/resources/src/main/res/drawable/ic_custom_server_l.xml new file mode 100644 index 0000000..e2a5f1e --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_server_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_server_m.xml b/resources/src/main/res/drawable/ic_custom_server_m.xml new file mode 100644 index 0000000..c0dc642 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_server_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_server_s.xml b/resources/src/main/res/drawable/ic_custom_server_s.xml new file mode 100644 index 0000000..1b3efd2 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_server_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_shopping2_l.xml b/resources/src/main/res/drawable/ic_custom_shopping2_l.xml new file mode 100644 index 0000000..5b72c85 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_shopping2_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_shopping2_m.xml b/resources/src/main/res/drawable/ic_custom_shopping2_m.xml new file mode 100644 index 0000000..b327b44 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_shopping2_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_shopping2_s.xml b/resources/src/main/res/drawable/ic_custom_shopping2_s.xml new file mode 100644 index 0000000..eaf0b26 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_shopping2_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_shopping_l.xml b/resources/src/main/res/drawable/ic_custom_shopping_l.xml new file mode 100644 index 0000000..7e1894b --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_shopping_l.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_shopping_m.xml b/resources/src/main/res/drawable/ic_custom_shopping_m.xml new file mode 100644 index 0000000..b1f3039 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_shopping_m.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_shopping_s.xml b/resources/src/main/res/drawable/ic_custom_shopping_s.xml new file mode 100644 index 0000000..24634cc --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_shopping_s.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_sports_l.xml b/resources/src/main/res/drawable/ic_custom_sports_l.xml new file mode 100644 index 0000000..0d12e90 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_sports_l.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_sports_m.xml b/resources/src/main/res/drawable/ic_custom_sports_m.xml new file mode 100644 index 0000000..cee398f --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_sports_m.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_sports_s.xml b/resources/src/main/res/drawable/ic_custom_sports_s.xml new file mode 100644 index 0000000..5570ddb --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_sports_s.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_star_l.xml b/resources/src/main/res/drawable/ic_custom_star_l.xml new file mode 100644 index 0000000..b157428 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_star_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_star_m.xml b/resources/src/main/res/drawable/ic_custom_star_m.xml new file mode 100644 index 0000000..cac0ea3 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_star_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_star_s.xml b/resources/src/main/res/drawable/ic_custom_star_s.xml new file mode 100644 index 0000000..9c21f55 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_star_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_stats_l.xml b/resources/src/main/res/drawable/ic_custom_stats_l.xml new file mode 100644 index 0000000..81a8f93 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_stats_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_stats_m.xml b/resources/src/main/res/drawable/ic_custom_stats_m.xml new file mode 100644 index 0000000..3a10104 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_stats_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_stats_s.xml b/resources/src/main/res/drawable/ic_custom_stats_s.xml new file mode 100644 index 0000000..c23b61b --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_stats_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_tools_l.xml b/resources/src/main/res/drawable/ic_custom_tools_l.xml new file mode 100644 index 0000000..8651387 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_tools_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_tools_m.xml b/resources/src/main/res/drawable/ic_custom_tools_m.xml new file mode 100644 index 0000000..ba4f3f2 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_tools_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_tools_s.xml b/resources/src/main/res/drawable/ic_custom_tools_s.xml new file mode 100644 index 0000000..46359d5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_tools_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_transfer_m.xml b/resources/src/main/res/drawable/ic_custom_transfer_m.xml new file mode 100644 index 0000000..033b327 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_transfer_m.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_custom_transport_l.xml b/resources/src/main/res/drawable/ic_custom_transport_l.xml new file mode 100644 index 0000000..40a49e2 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_transport_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_transport_m.xml b/resources/src/main/res/drawable/ic_custom_transport_m.xml new file mode 100644 index 0000000..0eafc20 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_transport_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_transport_s.xml b/resources/src/main/res/drawable/ic_custom_transport_s.xml new file mode 100644 index 0000000..a6f4b1b --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_transport_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_travel_l.xml b/resources/src/main/res/drawable/ic_custom_travel_l.xml new file mode 100644 index 0000000..78f1545 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_travel_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_travel_m.xml b/resources/src/main/res/drawable/ic_custom_travel_m.xml new file mode 100644 index 0000000..0c68440 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_travel_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_travel_s.xml b/resources/src/main/res/drawable/ic_custom_travel_s.xml new file mode 100644 index 0000000..a0e6110 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_travel_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_trees_l.xml b/resources/src/main/res/drawable/ic_custom_trees_l.xml new file mode 100644 index 0000000..2e906b8 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_trees_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_trees_m.xml b/resources/src/main/res/drawable/ic_custom_trees_m.xml new file mode 100644 index 0000000..04376ea --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_trees_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_trees_s.xml b/resources/src/main/res/drawable/ic_custom_trees_s.xml new file mode 100644 index 0000000..d25867d --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_trees_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_vehicle_l.xml b/resources/src/main/res/drawable/ic_custom_vehicle_l.xml new file mode 100644 index 0000000..2be87fb --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_vehicle_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_vehicle_m.xml b/resources/src/main/res/drawable/ic_custom_vehicle_m.xml new file mode 100644 index 0000000..8482ae0 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_vehicle_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_vehicle_s.xml b/resources/src/main/res/drawable/ic_custom_vehicle_s.xml new file mode 100644 index 0000000..906e581 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_vehicle_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_work_l.xml b/resources/src/main/res/drawable/ic_custom_work_l.xml new file mode 100644 index 0000000..43ec0f1 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_work_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_work_m.xml b/resources/src/main/res/drawable/ic_custom_work_m.xml new file mode 100644 index 0000000..dd69e67 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_work_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_work_s.xml b/resources/src/main/res/drawable/ic_custom_work_s.xml new file mode 100644 index 0000000..e4ba8ef --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_work_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_xrp_l.xml b/resources/src/main/res/drawable/ic_custom_xrp_l.xml new file mode 100644 index 0000000..1795676 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_xrp_l.xml @@ -0,0 +1,10 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_xrp_m.xml b/resources/src/main/res/drawable/ic_custom_xrp_m.xml new file mode 100644 index 0000000..d657d62 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_xrp_m.xml @@ -0,0 +1,10 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_xrp_s.xml b/resources/src/main/res/drawable/ic_custom_xrp_s.xml new file mode 100644 index 0000000..fa2f076 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_xrp_s.xml @@ -0,0 +1,10 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_zeus_l.xml b/resources/src/main/res/drawable/ic_custom_zeus_l.xml new file mode 100644 index 0000000..1a9ff1d --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_zeus_l.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_zeus_m.xml b/resources/src/main/res/drawable/ic_custom_zeus_m.xml new file mode 100644 index 0000000..dfda7e0 --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_zeus_m.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_custom_zeus_s.xml b/resources/src/main/res/drawable/ic_custom_zeus_s.xml new file mode 100644 index 0000000..68ecfac --- /dev/null +++ b/resources/src/main/res/drawable/ic_custom_zeus_s.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_darkmode.xml b/resources/src/main/res/drawable/ic_darkmode.xml new file mode 100644 index 0000000..0a8aed5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_darkmode.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_data_synced.xml b/resources/src/main/res/drawable/ic_data_synced.xml new file mode 100644 index 0000000..6b7e068 --- /dev/null +++ b/resources/src/main/res/drawable/ic_data_synced.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_date.xml b/resources/src/main/res/drawable/ic_date.xml new file mode 100644 index 0000000..04ddd37 --- /dev/null +++ b/resources/src/main/res/drawable/ic_date.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_delete.xml b/resources/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..0ff695e --- /dev/null +++ b/resources/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_description.xml b/resources/src/main/res/drawable/ic_description.xml new file mode 100644 index 0000000..4bc5c5a --- /dev/null +++ b/resources/src/main/res/drawable/ic_description.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_dismiss.xml b/resources/src/main/res/drawable/ic_dismiss.xml new file mode 100644 index 0000000..57f0e74 --- /dev/null +++ b/resources/src/main/res/drawable/ic_dismiss.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_dismiss_close.xml b/resources/src/main/res/drawable/ic_dismiss_close.xml new file mode 100644 index 0000000..ca1426b --- /dev/null +++ b/resources/src/main/res/drawable/ic_dismiss_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/resources/src/main/res/drawable/ic_divider.xml b/resources/src/main/res/drawable/ic_divider.xml new file mode 100644 index 0000000..9c46cec --- /dev/null +++ b/resources/src/main/res/drawable/ic_divider.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_donate_crown.xml b/resources/src/main/res/drawable/ic_donate_crown.xml new file mode 100644 index 0000000..5ce3518 --- /dev/null +++ b/resources/src/main/res/drawable/ic_donate_crown.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_donate_minus.xml b/resources/src/main/res/drawable/ic_donate_minus.xml new file mode 100644 index 0000000..7cf76b0 --- /dev/null +++ b/resources/src/main/res/drawable/ic_donate_minus.xml @@ -0,0 +1,29 @@ + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_donate_plus.xml b/resources/src/main/res/drawable/ic_donate_plus.xml new file mode 100644 index 0000000..46c62e5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_donate_plus.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_done.xml b/resources/src/main/res/drawable/ic_done.xml new file mode 100644 index 0000000..5f2d19b --- /dev/null +++ b/resources/src/main/res/drawable/ic_done.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_drag_handle.xml b/resources/src/main/res/drawable/ic_drag_handle.xml new file mode 100644 index 0000000..12b4fa7 --- /dev/null +++ b/resources/src/main/res/drawable/ic_drag_handle.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_due_date.xml b/resources/src/main/res/drawable/ic_due_date.xml new file mode 100644 index 0000000..5243000 --- /dev/null +++ b/resources/src/main/res/drawable/ic_due_date.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_duedate.xml b/resources/src/main/res/drawable/ic_duedate.xml new file mode 100644 index 0000000..49f9793 --- /dev/null +++ b/resources/src/main/res/drawable/ic_duedate.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_edit.xml b/resources/src/main/res/drawable/ic_edit.xml new file mode 100644 index 0000000..5406967 --- /dev/null +++ b/resources/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_email.xml b/resources/src/main/res/drawable/ic_email.xml new file mode 100644 index 0000000..7cabcf4 --- /dev/null +++ b/resources/src/main/res/drawable/ic_email.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_expand_less.xml b/resources/src/main/res/drawable/ic_expand_less.xml new file mode 100644 index 0000000..376c5aa --- /dev/null +++ b/resources/src/main/res/drawable/ic_expand_less.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_expand_more.xml b/resources/src/main/res/drawable/ic_expand_more.xml new file mode 100644 index 0000000..0261aa8 --- /dev/null +++ b/resources/src/main/res/drawable/ic_expand_more.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_expandarrow.xml b/resources/src/main/res/drawable/ic_expandarrow.xml new file mode 100644 index 0000000..201228d --- /dev/null +++ b/resources/src/main/res/drawable/ic_expandarrow.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_expense.xml b/resources/src/main/res/drawable/ic_expense.xml new file mode 100644 index 0000000..7217ed6 --- /dev/null +++ b/resources/src/main/res/drawable/ic_expense.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_export_csv.xml b/resources/src/main/res/drawable/ic_export_csv.xml new file mode 100644 index 0000000..766019a --- /dev/null +++ b/resources/src/main/res/drawable/ic_export_csv.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_export_csv_no_padding.xml b/resources/src/main/res/drawable/ic_export_csv_no_padding.xml new file mode 100644 index 0000000..68c885a --- /dev/null +++ b/resources/src/main/res/drawable/ic_export_csv_no_padding.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_file.xml b/resources/src/main/res/drawable/ic_file.xml new file mode 100644 index 0000000..336ae68 --- /dev/null +++ b/resources/src/main/res/drawable/ic_file.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_filter_l.xml b/resources/src/main/res/drawable/ic_filter_l.xml new file mode 100644 index 0000000..cf1836d --- /dev/null +++ b/resources/src/main/res/drawable/ic_filter_l.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_filter_xs.xml b/resources/src/main/res/drawable/ic_filter_xs.xml new file mode 100644 index 0000000..611b0ad --- /dev/null +++ b/resources/src/main/res/drawable/ic_filter_xs.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_fingerprint.xml b/resources/src/main/res/drawable/ic_fingerprint.xml new file mode 100644 index 0000000..36d0f4a --- /dev/null +++ b/resources/src/main/res/drawable/ic_fingerprint.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_format_text.xml b/resources/src/main/res/drawable/ic_format_text.xml new file mode 100644 index 0000000..8fe6913 --- /dev/null +++ b/resources/src/main/res/drawable/ic_format_text.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_google.xml b/resources/src/main/res/drawable/ic_google.xml new file mode 100644 index 0000000..0d6db59 --- /dev/null +++ b/resources/src/main/res/drawable/ic_google.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_hamburger.xml b/resources/src/main/res/drawable/ic_hamburger.xml new file mode 100644 index 0000000..d541bc9 --- /dev/null +++ b/resources/src/main/res/drawable/ic_hamburger.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_hashtag.xml b/resources/src/main/res/drawable/ic_hashtag.xml new file mode 100644 index 0000000..597ab5f --- /dev/null +++ b/resources/src/main/res/drawable/ic_hashtag.xml @@ -0,0 +1,9 @@ + + + diff --git a/resources/src/main/res/drawable/ic_hidden.xml b/resources/src/main/res/drawable/ic_hidden.xml new file mode 100644 index 0000000..49d626e --- /dev/null +++ b/resources/src/main/res/drawable/ic_hidden.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_hide_m.xml b/resources/src/main/res/drawable/ic_hide_m.xml new file mode 100644 index 0000000..418f3df --- /dev/null +++ b/resources/src/main/res/drawable/ic_hide_m.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_home.xml b/resources/src/main/res/drawable/ic_home.xml new file mode 100644 index 0000000..a0d8b6d --- /dev/null +++ b/resources/src/main/res/drawable/ic_home.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_import_video.xml b/resources/src/main/res/drawable/ic_import_video.xml new file mode 100644 index 0000000..744cee3 --- /dev/null +++ b/resources/src/main/res/drawable/ic_import_video.xml @@ -0,0 +1,9 @@ + + + diff --git a/resources/src/main/res/drawable/ic_import_web.xml b/resources/src/main/res/drawable/ic_import_web.xml new file mode 100644 index 0000000..2542590 --- /dev/null +++ b/resources/src/main/res/drawable/ic_import_web.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_income.xml b/resources/src/main/res/drawable/ic_income.xml new file mode 100644 index 0000000..fa33025 --- /dev/null +++ b/resources/src/main/res/drawable/ic_income.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_income_white.xml b/resources/src/main/res/drawable/ic_income_white.xml new file mode 100644 index 0000000..5ea7103 --- /dev/null +++ b/resources/src/main/res/drawable/ic_income_white.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_ivy_logo.xml b/resources/src/main/res/drawable/ic_ivy_logo.xml new file mode 100644 index 0000000..aa997bb --- /dev/null +++ b/resources/src/main/res/drawable/ic_ivy_logo.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_label_hashtag.xml b/resources/src/main/res/drawable/ic_label_hashtag.xml new file mode 100644 index 0000000..0d2d145 --- /dev/null +++ b/resources/src/main/res/drawable/ic_label_hashtag.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_launcher_background.xml b/resources/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..cfd1b6f --- /dev/null +++ b/resources/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_launcher_foreground.xml b/resources/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..4c236a6 --- /dev/null +++ b/resources/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_lightmode.xml b/resources/src/main/res/drawable/ic_lightmode.xml new file mode 100644 index 0000000..afeb223 --- /dev/null +++ b/resources/src/main/res/drawable/ic_lightmode.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_local_account.xml b/resources/src/main/res/drawable/ic_local_account.xml new file mode 100644 index 0000000..34c9762 --- /dev/null +++ b/resources/src/main/res/drawable/ic_local_account.xml @@ -0,0 +1,14 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_login.xml b/resources/src/main/res/drawable/ic_login.xml new file mode 100644 index 0000000..df80d31 --- /dev/null +++ b/resources/src/main/res/drawable/ic_login.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_logo.xml b/resources/src/main/res/drawable/ic_logo.xml new file mode 100644 index 0000000..104c481 --- /dev/null +++ b/resources/src/main/res/drawable/ic_logo.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_logout.xml b/resources/src/main/res/drawable/ic_logout.xml new file mode 100644 index 0000000..e4b55cc --- /dev/null +++ b/resources/src/main/res/drawable/ic_logout.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_modal_attachment_file.xml b/resources/src/main/res/drawable/ic_modal_attachment_file.xml new file mode 100644 index 0000000..1816889 --- /dev/null +++ b/resources/src/main/res/drawable/ic_modal_attachment_file.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_modal_attachment_link.xml b/resources/src/main/res/drawable/ic_modal_attachment_link.xml new file mode 100644 index 0000000..19ed181 --- /dev/null +++ b/resources/src/main/res/drawable/ic_modal_attachment_link.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_modal_reminder.xml b/resources/src/main/res/drawable/ic_modal_reminder.xml new file mode 100644 index 0000000..e969407 --- /dev/null +++ b/resources/src/main/res/drawable/ic_modal_reminder.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_monefy.xml b/resources/src/main/res/drawable/ic_monefy.xml new file mode 100644 index 0000000..932ca8c --- /dev/null +++ b/resources/src/main/res/drawable/ic_monefy.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_money_lover.xml b/resources/src/main/res/drawable/ic_money_lover.xml new file mode 100644 index 0000000..206396b --- /dev/null +++ b/resources/src/main/res/drawable/ic_money_lover.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_notes.xml b/resources/src/main/res/drawable/ic_notes.xml new file mode 100644 index 0000000..dfbae18 --- /dev/null +++ b/resources/src/main/res/drawable/ic_notes.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_notification.xml b/resources/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..b55eda0 --- /dev/null +++ b/resources/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_notification_m.xml b/resources/src/main/res/drawable/ic_notification_m.xml new file mode 100644 index 0000000..e401bbd --- /dev/null +++ b/resources/src/main/res/drawable/ic_notification_m.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_notransactions.xml b/resources/src/main/res/drawable/ic_notransactions.xml new file mode 100644 index 0000000..22461db --- /dev/null +++ b/resources/src/main/res/drawable/ic_notransactions.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_onboarding_next_arrow.xml b/resources/src/main/res/drawable/ic_onboarding_next_arrow.xml new file mode 100644 index 0000000..ae30167 --- /dev/null +++ b/resources/src/main/res/drawable/ic_onboarding_next_arrow.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_options.xml b/resources/src/main/res/drawable/ic_options.xml new file mode 100644 index 0000000..f78d10b --- /dev/null +++ b/resources/src/main/res/drawable/ic_options.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_outline_clear_24.xml b/resources/src/main/res/drawable/ic_outline_clear_24.xml new file mode 100644 index 0000000..4ebf4a0 --- /dev/null +++ b/resources/src/main/res/drawable/ic_outline_clear_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_overdue.xml b/resources/src/main/res/drawable/ic_overdue.xml new file mode 100644 index 0000000..c1e074a --- /dev/null +++ b/resources/src/main/res/drawable/ic_overdue.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_planned_payments.xml b/resources/src/main/res/drawable/ic_planned_payments.xml new file mode 100644 index 0000000..166241b --- /dev/null +++ b/resources/src/main/res/drawable/ic_planned_payments.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_plus.xml b/resources/src/main/res/drawable/ic_plus.xml new file mode 100644 index 0000000..a8cf840 --- /dev/null +++ b/resources/src/main/res/drawable/ic_plus.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_popup_add.xml b/resources/src/main/res/drawable/ic_popup_add.xml new file mode 100644 index 0000000..ebb9f58 --- /dev/null +++ b/resources/src/main/res/drawable/ic_popup_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/resources/src/main/res/drawable/ic_popup_close.xml b/resources/src/main/res/drawable/ic_popup_close.xml new file mode 100644 index 0000000..fe567d9 --- /dev/null +++ b/resources/src/main/res/drawable/ic_popup_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/resources/src/main/res/drawable/ic_popup_collapse.xml b/resources/src/main/res/drawable/ic_popup_collapse.xml new file mode 100644 index 0000000..35aff7b --- /dev/null +++ b/resources/src/main/res/drawable/ic_popup_collapse.xml @@ -0,0 +1,9 @@ + + + diff --git a/resources/src/main/res/drawable/ic_popup_expand.xml b/resources/src/main/res/drawable/ic_popup_expand.xml new file mode 100644 index 0000000..0768d17 --- /dev/null +++ b/resources/src/main/res/drawable/ic_popup_expand.xml @@ -0,0 +1,9 @@ + + + diff --git a/resources/src/main/res/drawable/ic_premium_big.xml b/resources/src/main/res/drawable/ic_premium_big.xml new file mode 100644 index 0000000..2885acb --- /dev/null +++ b/resources/src/main/res/drawable/ic_premium_big.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_premium_small.xml b/resources/src/main/res/drawable/ic_premium_small.xml new file mode 100644 index 0000000..a4e88b9 --- /dev/null +++ b/resources/src/main/res/drawable/ic_premium_small.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_priority_dropdown.xml b/resources/src/main/res/drawable/ic_priority_dropdown.xml new file mode 100644 index 0000000..1e70edb --- /dev/null +++ b/resources/src/main/res/drawable/ic_priority_dropdown.xml @@ -0,0 +1,9 @@ + + + diff --git a/resources/src/main/res/drawable/ic_profile.xml b/resources/src/main/res/drawable/ic_profile.xml new file mode 100644 index 0000000..30bc587 --- /dev/null +++ b/resources/src/main/res/drawable/ic_profile.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_recurring.xml b/resources/src/main/res/drawable/ic_recurring.xml new file mode 100644 index 0000000..639eb81 --- /dev/null +++ b/resources/src/main/res/drawable/ic_recurring.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_recurring_next.xml b/resources/src/main/res/drawable/ic_recurring_next.xml new file mode 100644 index 0000000..ce9d9b2 --- /dev/null +++ b/resources/src/main/res/drawable/ic_recurring_next.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_recurring_upcoming.xml b/resources/src/main/res/drawable/ic_recurring_upcoming.xml new file mode 100644 index 0000000..a20071d --- /dev/null +++ b/resources/src/main/res/drawable/ic_recurring_upcoming.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_refresh.xml b/resources/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..566500b --- /dev/null +++ b/resources/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_remove.xml b/resources/src/main/res/drawable/ic_remove.xml new file mode 100644 index 0000000..5ab1d4b --- /dev/null +++ b/resources/src/main/res/drawable/ic_remove.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_remove_checklist_item.xml b/resources/src/main/res/drawable/ic_remove_checklist_item.xml new file mode 100644 index 0000000..5eb37b6 --- /dev/null +++ b/resources/src/main/res/drawable/ic_remove_checklist_item.xml @@ -0,0 +1,9 @@ + + + diff --git a/resources/src/main/res/drawable/ic_reorder.xml b/resources/src/main/res/drawable/ic_reorder.xml new file mode 100644 index 0000000..3672dbd --- /dev/null +++ b/resources/src/main/res/drawable/ic_reorder.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_reset_password.xml b/resources/src/main/res/drawable/ic_reset_password.xml new file mode 100644 index 0000000..3a31e64 --- /dev/null +++ b/resources/src/main/res/drawable/ic_reset_password.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_round_add_24.xml b/resources/src/main/res/drawable/ic_round_add_24.xml new file mode 100644 index 0000000..fc99f3a --- /dev/null +++ b/resources/src/main/res/drawable/ic_round_add_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_round_calculate_24.xml b/resources/src/main/res/drawable/ic_round_calculate_24.xml new file mode 100644 index 0000000..defa192 --- /dev/null +++ b/resources/src/main/res/drawable/ic_round_calculate_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_round_calendar_month_24.xml b/resources/src/main/res/drawable/ic_round_calendar_month_24.xml new file mode 100644 index 0000000..5fd7960 --- /dev/null +++ b/resources/src/main/res/drawable/ic_round_calendar_month_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_round_check_24.xml b/resources/src/main/res/drawable/ic_round_check_24.xml new file mode 100644 index 0000000..a38a112 --- /dev/null +++ b/resources/src/main/res/drawable/ic_round_check_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_round_close_24.xml b/resources/src/main/res/drawable/ic_round_close_24.xml new file mode 100644 index 0000000..63eb812 --- /dev/null +++ b/resources/src/main/res/drawable/ic_round_close_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_round_delete_forever_24.xml b/resources/src/main/res/drawable/ic_round_delete_forever_24.xml new file mode 100644 index 0000000..ae3f68a --- /dev/null +++ b/resources/src/main/res/drawable/ic_round_delete_forever_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_round_expand_less_24.xml b/resources/src/main/res/drawable/ic_round_expand_less_24.xml new file mode 100644 index 0000000..2546701 --- /dev/null +++ b/resources/src/main/res/drawable/ic_round_expand_less_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_round_more_horiz_24.xml b/resources/src/main/res/drawable/ic_round_more_horiz_24.xml new file mode 100644 index 0000000..0b5f28c --- /dev/null +++ b/resources/src/main/res/drawable/ic_round_more_horiz_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_round_undo_24.xml b/resources/src/main/res/drawable/ic_round_undo_24.xml new file mode 100644 index 0000000..907c216 --- /dev/null +++ b/resources/src/main/res/drawable/ic_round_undo_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/resources/src/main/res/drawable/ic_save.xml b/resources/src/main/res/drawable/ic_save.xml new file mode 100644 index 0000000..3a6dab5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_save.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_search.xml b/resources/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..59fcaf5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_search.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_secure.xml b/resources/src/main/res/drawable/ic_secure.xml new file mode 100644 index 0000000..70eedd5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_secure.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_settings.xml b/resources/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..2b427e2 --- /dev/null +++ b/resources/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_share.xml b/resources/src/main/res/drawable/ic_share.xml new file mode 100644 index 0000000..27dc03d --- /dev/null +++ b/resources/src/main/res/drawable/ic_share.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_side_menu_collapsed.xml b/resources/src/main/res/drawable/ic_side_menu_collapsed.xml new file mode 100644 index 0000000..068dfb5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_side_menu_collapsed.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_side_menu_expanded.xml b/resources/src/main/res/drawable/ic_side_menu_expanded.xml new file mode 100644 index 0000000..7c6c375 --- /dev/null +++ b/resources/src/main/res/drawable/ic_side_menu_expanded.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_sort_by_alpha_24.xml b/resources/src/main/res/drawable/ic_sort_by_alpha_24.xml new file mode 100644 index 0000000..200bdd4 --- /dev/null +++ b/resources/src/main/res/drawable/ic_sort_by_alpha_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/resources/src/main/res/drawable/ic_speendee.xml b/resources/src/main/res/drawable/ic_speendee.xml new file mode 100644 index 0000000..72e9547 --- /dev/null +++ b/resources/src/main/res/drawable/ic_speendee.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_statistics_s.xml b/resources/src/main/res/drawable/ic_statistics_s.xml new file mode 100644 index 0000000..3de4113 --- /dev/null +++ b/resources/src/main/res/drawable/ic_statistics_s.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_statistics_xs.xml b/resources/src/main/res/drawable/ic_statistics_xs.xml new file mode 100644 index 0000000..873d0d8 --- /dev/null +++ b/resources/src/main/res/drawable/ic_statistics_xs.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_support.xml b/resources/src/main/res/drawable/ic_support.xml new file mode 100644 index 0000000..8357087 --- /dev/null +++ b/resources/src/main/res/drawable/ic_support.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_swipe_horizontal.xml b/resources/src/main/res/drawable/ic_swipe_horizontal.xml new file mode 100644 index 0000000..1c13178 --- /dev/null +++ b/resources/src/main/res/drawable/ic_swipe_horizontal.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_swipe_up.xml b/resources/src/main/res/drawable/ic_swipe_up.xml new file mode 100644 index 0000000..16eec28 --- /dev/null +++ b/resources/src/main/res/drawable/ic_swipe_up.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_swipe_up_dark.xml b/resources/src/main/res/drawable/ic_swipe_up_dark.xml new file mode 100644 index 0000000..98d9d97 --- /dev/null +++ b/resources/src/main/res/drawable/ic_swipe_up_dark.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_sync.xml b/resources/src/main/res/drawable/ic_sync.xml new file mode 100644 index 0000000..afb7642 --- /dev/null +++ b/resources/src/main/res/drawable/ic_sync.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_tasks.xml b/resources/src/main/res/drawable/ic_tasks.xml new file mode 100644 index 0000000..16668b9 --- /dev/null +++ b/resources/src/main/res/drawable/ic_tasks.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_telegram_24dp.xml b/resources/src/main/res/drawable/ic_telegram_24dp.xml new file mode 100644 index 0000000..74e5690 --- /dev/null +++ b/resources/src/main/res/drawable/ic_telegram_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/resources/src/main/res/drawable/ic_template.xml b/resources/src/main/res/drawable/ic_template.xml new file mode 100644 index 0000000..fb2c343 --- /dev/null +++ b/resources/src/main/res/drawable/ic_template.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_time.xml b/resources/src/main/res/drawable/ic_time.xml new file mode 100644 index 0000000..c83e0da --- /dev/null +++ b/resources/src/main/res/drawable/ic_time.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_time_tracking_log_entry.xml b/resources/src/main/res/drawable/ic_time_tracking_log_entry.xml new file mode 100644 index 0000000..15634fe --- /dev/null +++ b/resources/src/main/res/drawable/ic_time_tracking_log_entry.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_time_tracking_pause.xml b/resources/src/main/res/drawable/ic_time_tracking_pause.xml new file mode 100644 index 0000000..67b4784 --- /dev/null +++ b/resources/src/main/res/drawable/ic_time_tracking_pause.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_time_tracking_play.xml b/resources/src/main/res/drawable/ic_time_tracking_play.xml new file mode 100644 index 0000000..3029f1a --- /dev/null +++ b/resources/src/main/res/drawable/ic_time_tracking_play.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_timetracking.xml b/resources/src/main/res/drawable/ic_timetracking.xml new file mode 100644 index 0000000..5648600 --- /dev/null +++ b/resources/src/main/res/drawable/ic_timetracking.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_toshl_finance.xml b/resources/src/main/res/drawable/ic_toshl_finance.xml new file mode 100644 index 0000000..75d96a3 --- /dev/null +++ b/resources/src/main/res/drawable/ic_toshl_finance.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_transfer.xml b/resources/src/main/res/drawable/ic_transfer.xml new file mode 100644 index 0000000..f2a2439 --- /dev/null +++ b/resources/src/main/res/drawable/ic_transfer.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_upload.xml b/resources/src/main/res/drawable/ic_upload.xml new file mode 100644 index 0000000..4a8893f --- /dev/null +++ b/resources/src/main/res/drawable/ic_upload.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_visible.xml b/resources/src/main/res/drawable/ic_visible.xml new file mode 100644 index 0000000..5dc6e47 --- /dev/null +++ b/resources/src/main/res/drawable/ic_visible.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_android.xml b/resources/src/main/res/drawable/ic_vue_brands_android.xml new file mode 100644 index 0000000..9657d08 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_android.xml @@ -0,0 +1,55 @@ + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_apple.xml b/resources/src/main/res/drawable/ic_vue_brands_apple.xml new file mode 100644 index 0000000..1e51d0e --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_apple.xml @@ -0,0 +1,14 @@ + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_be.xml b/resources/src/main/res/drawable/ic_vue_brands_be.xml new file mode 100644 index 0000000..61d8431 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_be.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_blogger.xml b/resources/src/main/res/drawable/ic_vue_brands_blogger.xml new file mode 100644 index 0000000..0943498 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_blogger.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_bootsrap.xml b/resources/src/main/res/drawable/ic_vue_brands_bootsrap.xml new file mode 100644 index 0000000..30e691e --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_bootsrap.xml @@ -0,0 +1,19 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_dribbble.xml b/resources/src/main/res/drawable/ic_vue_brands_dribbble.xml new file mode 100644 index 0000000..9a95374 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_dribbble.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_drive.xml b/resources/src/main/res/drawable/ic_vue_brands_drive.xml new file mode 100644 index 0000000..4680453 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_drive.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_dropbox.xml b/resources/src/main/res/drawable/ic_vue_brands_dropbox.xml new file mode 100644 index 0000000..b7c8e98 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_dropbox.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_facebook.xml b/resources/src/main/res/drawable/ic_vue_brands_facebook.xml new file mode 100644 index 0000000..35dc71a --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_facebook.xml @@ -0,0 +1,19 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_figma.xml b/resources/src/main/res/drawable/ic_vue_brands_figma.xml new file mode 100644 index 0000000..63edbca --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_figma.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_framer.xml b/resources/src/main/res/drawable/ic_vue_brands_framer.xml new file mode 100644 index 0000000..9f9cfd9 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_framer.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_google.xml b/resources/src/main/res/drawable/ic_vue_brands_google.xml new file mode 100644 index 0000000..6afb91b --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_google.xml @@ -0,0 +1,13 @@ + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_google_play.xml b/resources/src/main/res/drawable/ic_vue_brands_google_play.xml new file mode 100644 index 0000000..ef44f8d --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_google_play.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_html3.xml b/resources/src/main/res/drawable/ic_vue_brands_html3.xml new file mode 100644 index 0000000..f74bfb4 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_html3.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_html5.xml b/resources/src/main/res/drawable/ic_vue_brands_html5.xml new file mode 100644 index 0000000..7621567 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_html5.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_illustrator.xml b/resources/src/main/res/drawable/ic_vue_brands_illustrator.xml new file mode 100644 index 0000000..ec98985 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_illustrator.xml @@ -0,0 +1,39 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_js.xml b/resources/src/main/res/drawable/ic_vue_brands_js.xml new file mode 100644 index 0000000..3776223 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_js.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_messenger.xml b/resources/src/main/res/drawable/ic_vue_brands_messenger.xml new file mode 100644 index 0000000..c5ac881 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_messenger.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_ok.xml b/resources/src/main/res/drawable/ic_vue_brands_ok.xml new file mode 100644 index 0000000..a9788c2 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_ok.xml @@ -0,0 +1,30 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_paypal.xml b/resources/src/main/res/drawable/ic_vue_brands_paypal.xml new file mode 100644 index 0000000..204ad25 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_paypal.xml @@ -0,0 +1,16 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_photoshop.xml b/resources/src/main/res/drawable/ic_vue_brands_photoshop.xml new file mode 100644 index 0000000..5c5d863 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_photoshop.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_python.xml b/resources/src/main/res/drawable/ic_vue_brands_python.xml new file mode 100644 index 0000000..5f82a39 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_python.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_slack.xml b/resources/src/main/res/drawable/ic_vue_brands_slack.xml new file mode 100644 index 0000000..674c2b6 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_slack.xml @@ -0,0 +1,62 @@ + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_snapchat.xml b/resources/src/main/res/drawable/ic_vue_brands_snapchat.xml new file mode 100644 index 0000000..1bde6be --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_snapchat.xml @@ -0,0 +1,19 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_spotify.xml b/resources/src/main/res/drawable/ic_vue_brands_spotify.xml new file mode 100644 index 0000000..991e2f2 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_spotify.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_trello.xml b/resources/src/main/res/drawable/ic_vue_brands_trello.xml new file mode 100644 index 0000000..987ab29 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_trello.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_triangle.xml b/resources/src/main/res/drawable/ic_vue_brands_triangle.xml new file mode 100644 index 0000000..4f8be57 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_triangle.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_twitch.xml b/resources/src/main/res/drawable/ic_vue_brands_twitch.xml new file mode 100644 index 0000000..9fe76a5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_twitch.xml @@ -0,0 +1,28 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_ui8.xml b/resources/src/main/res/drawable/ic_vue_brands_ui8.xml new file mode 100644 index 0000000..eaeb532 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_ui8.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_vuesax.xml b/resources/src/main/res/drawable/ic_vue_brands_vuesax.xml new file mode 100644 index 0000000..e71fc3f --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_vuesax.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_whatsapp.xml b/resources/src/main/res/drawable/ic_vue_brands_whatsapp.xml new file mode 100644 index 0000000..be10aac --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_whatsapp.xml @@ -0,0 +1,18 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_windows.xml b/resources/src/main/res/drawable/ic_vue_brands_windows.xml new file mode 100644 index 0000000..2454589 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_windows.xml @@ -0,0 +1,38 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_xd.xml b/resources/src/main/res/drawable/ic_vue_brands_xd.xml new file mode 100644 index 0000000..b33a0f8 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_xd.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_xiaomi.xml b/resources/src/main/res/drawable/ic_vue_brands_xiaomi.xml new file mode 100644 index 0000000..dd3e03b --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_xiaomi.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_youtube.xml b/resources/src/main/res/drawable/ic_vue_brands_youtube.xml new file mode 100644 index 0000000..c4aded8 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_youtube.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_brands_zoom.xml b/resources/src/main/res/drawable/ic_vue_brands_zoom.xml new file mode 100644 index 0000000..e52733c --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_brands_zoom.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_building_bank.xml b/resources/src/main/res/drawable/ic_vue_building_bank.xml new file mode 100644 index 0000000..c994785 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_building_bank.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_building_building.xml b/resources/src/main/res/drawable/ic_vue_building_building.xml new file mode 100644 index 0000000..3a8f07a --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_building_building.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_building_building1.xml b/resources/src/main/res/drawable/ic_vue_building_building1.xml new file mode 100644 index 0000000..827ca6a --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_building_building1.xml @@ -0,0 +1,55 @@ + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_building_buildings.xml b/resources/src/main/res/drawable/ic_vue_building_buildings.xml new file mode 100644 index 0000000..84e5712 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_building_buildings.xml @@ -0,0 +1,55 @@ + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_building_courthouse.xml b/resources/src/main/res/drawable/ic_vue_building_courthouse.xml new file mode 100644 index 0000000..29868fd --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_building_courthouse.xml @@ -0,0 +1,59 @@ + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_building_hospital.xml b/resources/src/main/res/drawable/ic_vue_building_hospital.xml new file mode 100644 index 0000000..e862128 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_building_hospital.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_building_house.xml b/resources/src/main/res/drawable/ic_vue_building_house.xml new file mode 100644 index 0000000..58b83a7 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_building_house.xml @@ -0,0 +1,44 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_chart_chart.xml b/resources/src/main/res/drawable/ic_vue_chart_chart.xml new file mode 100644 index 0000000..3a63665 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_chart_chart.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_chart_diagram.xml b/resources/src/main/res/drawable/ic_vue_chart_diagram.xml new file mode 100644 index 0000000..646314d --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_chart_diagram.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_chart_graph.xml b/resources/src/main/res/drawable/ic_vue_chart_graph.xml new file mode 100644 index 0000000..ffccfa6 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_chart_graph.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_chart_status_up.xml b/resources/src/main/res/drawable/ic_vue_chart_status_up.xml new file mode 100644 index 0000000..c5ee6a9 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_chart_status_up.xml @@ -0,0 +1,44 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_chart_trend_up.xml b/resources/src/main/res/drawable/ic_vue_chart_trend_up.xml new file mode 100644 index 0000000..b581a66 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_chart_trend_up.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_aave.xml b/resources/src/main/res/drawable/ic_vue_crypto_aave.xml new file mode 100644 index 0000000..a9b40e8 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_aave.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_ankr.xml b/resources/src/main/res/drawable/ic_vue_crypto_ankr.xml new file mode 100644 index 0000000..7b67444 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_ankr.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_augur.xml b/resources/src/main/res/drawable/ic_vue_crypto_augur.xml new file mode 100644 index 0000000..c973009 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_augur.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_autonio.xml b/resources/src/main/res/drawable/ic_vue_crypto_autonio.xml new file mode 100644 index 0000000..8f55d57 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_autonio.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_avalanche.xml b/resources/src/main/res/drawable/ic_vue_crypto_avalanche.xml new file mode 100644 index 0000000..66e02af --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_avalanche.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_binance_coin.xml b/resources/src/main/res/drawable/ic_vue_crypto_binance_coin.xml new file mode 100644 index 0000000..eb0a351 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_binance_coin.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_binance_usd.xml b/resources/src/main/res/drawable/ic_vue_crypto_binance_usd.xml new file mode 100644 index 0000000..9a0c4d5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_binance_usd.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_bitcoin.xml b/resources/src/main/res/drawable/ic_vue_crypto_bitcoin.xml new file mode 100644 index 0000000..0aec0f4 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_bitcoin.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_cardano.xml b/resources/src/main/res/drawable/ic_vue_crypto_cardano.xml new file mode 100644 index 0000000..02b1c64 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_cardano.xml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_celo.xml b/resources/src/main/res/drawable/ic_vue_crypto_celo.xml new file mode 100644 index 0000000..54b24ad --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_celo.xml @@ -0,0 +1,16 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_celsius_.xml b/resources/src/main/res/drawable/ic_vue_crypto_celsius_.xml new file mode 100644 index 0000000..9a283b7 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_celsius_.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_chainlink.xml b/resources/src/main/res/drawable/ic_vue_crypto_chainlink.xml new file mode 100644 index 0000000..f85d3c8 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_chainlink.xml @@ -0,0 +1,13 @@ + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_civic.xml b/resources/src/main/res/drawable/ic_vue_crypto_civic.xml new file mode 100644 index 0000000..24fe166 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_civic.xml @@ -0,0 +1,18 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_dai.xml b/resources/src/main/res/drawable/ic_vue_crypto_dai.xml new file mode 100644 index 0000000..a3e499d --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_dai.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_dash.xml b/resources/src/main/res/drawable/ic_vue_crypto_dash.xml new file mode 100644 index 0000000..0efd0b3 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_dash.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_decred.xml b/resources/src/main/res/drawable/ic_vue_crypto_decred.xml new file mode 100644 index 0000000..312e423 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_decred.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_dent.xml b/resources/src/main/res/drawable/ic_vue_crypto_dent.xml new file mode 100644 index 0000000..38f2a05 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_dent.xml @@ -0,0 +1,26 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_educare.xml b/resources/src/main/res/drawable/ic_vue_crypto_educare.xml new file mode 100644 index 0000000..0228938 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_educare.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_emercoin.xml b/resources/src/main/res/drawable/ic_vue_crypto_emercoin.xml new file mode 100644 index 0000000..cecfc9a --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_emercoin.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_enjin_coin.xml b/resources/src/main/res/drawable/ic_vue_crypto_enjin_coin.xml new file mode 100644 index 0000000..19bdcae --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_enjin_coin.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_eos.xml b/resources/src/main/res/drawable/ic_vue_crypto_eos.xml new file mode 100644 index 0000000..97d7129 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_eos.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_ethereum.xml b/resources/src/main/res/drawable/ic_vue_crypto_ethereum.xml new file mode 100644 index 0000000..eaf77fb --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_ethereum.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_ethereum_classic.xml b/resources/src/main/res/drawable/ic_vue_crypto_ethereum_classic.xml new file mode 100644 index 0000000..b59f5ef --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_ethereum_classic.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_ftx_token.xml b/resources/src/main/res/drawable/ic_vue_crypto_ftx_token.xml new file mode 100644 index 0000000..624ff5d --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_ftx_token.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_graph.xml b/resources/src/main/res/drawable/ic_vue_crypto_graph.xml new file mode 100644 index 0000000..e90d40b --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_graph.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_harmony.xml b/resources/src/main/res/drawable/ic_vue_crypto_harmony.xml new file mode 100644 index 0000000..5fc674d --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_harmony.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_hedera_hashgraph.xml b/resources/src/main/res/drawable/ic_vue_crypto_hedera_hashgraph.xml new file mode 100644 index 0000000..25c6dc5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_hedera_hashgraph.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_hex.xml b/resources/src/main/res/drawable/ic_vue_crypto_hex.xml new file mode 100644 index 0000000..4fc394d --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_hex.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_huobi_token.xml b/resources/src/main/res/drawable/ic_vue_crypto_huobi_token.xml new file mode 100644 index 0000000..fc1dc1e --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_huobi_token.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_icon.xml b/resources/src/main/res/drawable/ic_vue_crypto_icon.xml new file mode 100644 index 0000000..a26c3c6 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_icon.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_iost.xml b/resources/src/main/res/drawable/ic_vue_crypto_iost.xml new file mode 100644 index 0000000..6185c58 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_iost.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_kyber_network.xml b/resources/src/main/res/drawable/ic_vue_crypto_kyber_network.xml new file mode 100644 index 0000000..0c4d418 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_kyber_network.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_litecoin.xml b/resources/src/main/res/drawable/ic_vue_crypto_litecoin.xml new file mode 100644 index 0000000..1bbe862 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_litecoin.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_maker.xml b/resources/src/main/res/drawable/ic_vue_crypto_maker.xml new file mode 100644 index 0000000..3780581 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_maker.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_monero.xml b/resources/src/main/res/drawable/ic_vue_crypto_monero.xml new file mode 100644 index 0000000..e1619df --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_monero.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_nebulas.xml b/resources/src/main/res/drawable/ic_vue_crypto_nebulas.xml new file mode 100644 index 0000000..4073595 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_nebulas.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_nem.xml b/resources/src/main/res/drawable/ic_vue_crypto_nem.xml new file mode 100644 index 0000000..8d07183 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_nem.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_nexo.xml b/resources/src/main/res/drawable/ic_vue_crypto_nexo.xml new file mode 100644 index 0000000..a1acb69 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_nexo.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_ocean_protocol.xml b/resources/src/main/res/drawable/ic_vue_crypto_ocean_protocol.xml new file mode 100644 index 0000000..ebc080f --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_ocean_protocol.xml @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_okb.xml b/resources/src/main/res/drawable/ic_vue_crypto_okb.xml new file mode 100644 index 0000000..b9522c1 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_okb.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_ontology.xml b/resources/src/main/res/drawable/ic_vue_crypto_ontology.xml new file mode 100644 index 0000000..199dfe5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_ontology.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_polkadot.xml b/resources/src/main/res/drawable/ic_vue_crypto_polkadot.xml new file mode 100644 index 0000000..f4e8ea7 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_polkadot.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_polygon.xml b/resources/src/main/res/drawable/ic_vue_crypto_polygon.xml new file mode 100644 index 0000000..7538807 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_polygon.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_polyswarm.xml b/resources/src/main/res/drawable/ic_vue_crypto_polyswarm.xml new file mode 100644 index 0000000..dfd8737 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_polyswarm.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_quant.xml b/resources/src/main/res/drawable/ic_vue_crypto_quant.xml new file mode 100644 index 0000000..58e08c7 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_quant.xml @@ -0,0 +1,60 @@ + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_siacoin.xml b/resources/src/main/res/drawable/ic_vue_crypto_siacoin.xml new file mode 100644 index 0000000..fca16c2 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_siacoin.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_solana.xml b/resources/src/main/res/drawable/ic_vue_crypto_solana.xml new file mode 100644 index 0000000..384b4e0 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_solana.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_stacks.xml b/resources/src/main/res/drawable/ic_vue_crypto_stacks.xml new file mode 100644 index 0000000..39e71c3 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_stacks.xml @@ -0,0 +1,53 @@ + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_stellar.xml b/resources/src/main/res/drawable/ic_vue_crypto_stellar.xml new file mode 100644 index 0000000..32af9f1 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_stellar.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_tenx.xml b/resources/src/main/res/drawable/ic_vue_crypto_tenx.xml new file mode 100644 index 0000000..2d26ae2 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_tenx.xml @@ -0,0 +1,18 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_tether.xml b/resources/src/main/res/drawable/ic_vue_crypto_tether.xml new file mode 100644 index 0000000..92b8993 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_tether.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_theta.xml b/resources/src/main/res/drawable/ic_vue_crypto_theta.xml new file mode 100644 index 0000000..aa5c65b --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_theta.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_thorchain.xml b/resources/src/main/res/drawable/ic_vue_crypto_thorchain.xml new file mode 100644 index 0000000..628aab9 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_thorchain.xml @@ -0,0 +1,11 @@ + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_trontron.xml b/resources/src/main/res/drawable/ic_vue_crypto_trontron.xml new file mode 100644 index 0000000..c9ce671 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_trontron.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_usd_coin.xml b/resources/src/main/res/drawable/ic_vue_crypto_usd_coin.xml new file mode 100644 index 0000000..5ae01f4 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_usd_coin.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_velas.xml b/resources/src/main/res/drawable/ic_vue_crypto_velas.xml new file mode 100644 index 0000000..a207ef4 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_velas.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_vibe.xml b/resources/src/main/res/drawable/ic_vue_crypto_vibe.xml new file mode 100644 index 0000000..35af828 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_vibe.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_wanchain.xml b/resources/src/main/res/drawable/ic_vue_crypto_wanchain.xml new file mode 100644 index 0000000..f2685d9 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_wanchain.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_wing.xml b/resources/src/main/res/drawable/ic_vue_crypto_wing.xml new file mode 100644 index 0000000..5b1c703 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_wing.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_xrp.xml b/resources/src/main/res/drawable/ic_vue_crypto_xrp.xml new file mode 100644 index 0000000..b91b300 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_xrp.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_crypto_zel.xml b/resources/src/main/res/drawable/ic_vue_crypto_zel.xml new file mode 100644 index 0000000..6fc639d --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_crypto_zel.xml @@ -0,0 +1,30 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_delivery_box.xml b/resources/src/main/res/drawable/ic_vue_delivery_box.xml new file mode 100644 index 0000000..7d4d7b9 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_delivery_box.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_delivery_box1.xml b/resources/src/main/res/drawable/ic_vue_delivery_box1.xml new file mode 100644 index 0000000..4428fd4 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_delivery_box1.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_delivery_package.xml b/resources/src/main/res/drawable/ic_vue_delivery_package.xml new file mode 100644 index 0000000..0a2aa46 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_delivery_package.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_delivery_receive.xml b/resources/src/main/res/drawable/ic_vue_delivery_receive.xml new file mode 100644 index 0000000..e567b96 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_delivery_receive.xml @@ -0,0 +1,62 @@ + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_delivery_truck.xml b/resources/src/main/res/drawable/ic_vue_delivery_truck.xml new file mode 100644 index 0000000..110b50f --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_delivery_truck.xml @@ -0,0 +1,62 @@ + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_design_bezier.xml b/resources/src/main/res/drawable/ic_vue_design_bezier.xml new file mode 100644 index 0000000..2c56d71 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_design_bezier.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_design_brush.xml b/resources/src/main/res/drawable/ic_vue_design_brush.xml new file mode 100644 index 0000000..e89fb94 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_design_brush.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_design_color_swatch.xml b/resources/src/main/res/drawable/ic_vue_design_color_swatch.xml new file mode 100644 index 0000000..abaed00 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_design_color_swatch.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_design_magicpen.xml b/resources/src/main/res/drawable/ic_vue_design_magicpen.xml new file mode 100644 index 0000000..d0c7f05 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_design_magicpen.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_design_roller.xml b/resources/src/main/res/drawable/ic_vue_design_roller.xml new file mode 100644 index 0000000..79889e2 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_design_roller.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_design_scissors.xml b/resources/src/main/res/drawable/ic_vue_design_scissors.xml new file mode 100644 index 0000000..772298f --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_design_scissors.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_design_tool_pen.xml b/resources/src/main/res/drawable/ic_vue_design_tool_pen.xml new file mode 100644 index 0000000..b3a567f --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_design_tool_pen.xml @@ -0,0 +1,62 @@ + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_dev_arrow.xml b/resources/src/main/res/drawable/ic_vue_dev_arrow.xml new file mode 100644 index 0000000..1481352 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_dev_arrow.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_dev_code.xml b/resources/src/main/res/drawable/ic_vue_dev_code.xml new file mode 100644 index 0000000..2f8b22d --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_dev_code.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_dev_data.xml b/resources/src/main/res/drawable/ic_vue_dev_data.xml new file mode 100644 index 0000000..3710cea --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_dev_data.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_dev_hashtag.xml b/resources/src/main/res/drawable/ic_vue_dev_hashtag.xml new file mode 100644 index 0000000..b88081a --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_dev_hashtag.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_dev_hierarchy.xml b/resources/src/main/res/drawable/ic_vue_dev_hierarchy.xml new file mode 100644 index 0000000..749f223 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_dev_hierarchy.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_dev_relation.xml b/resources/src/main/res/drawable/ic_vue_dev_relation.xml new file mode 100644 index 0000000..94e9a0a --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_dev_relation.xml @@ -0,0 +1,58 @@ + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_edu_award.xml b/resources/src/main/res/drawable/ic_vue_edu_award.xml new file mode 100644 index 0000000..d05f07d --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_edu_award.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_edu_book.xml b/resources/src/main/res/drawable/ic_vue_edu_book.xml new file mode 100644 index 0000000..aed0e98 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_edu_book.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_edu_bookmark.xml b/resources/src/main/res/drawable/ic_vue_edu_bookmark.xml new file mode 100644 index 0000000..f18a1c4 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_edu_bookmark.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_edu_briefcase.xml b/resources/src/main/res/drawable/ic_vue_edu_briefcase.xml new file mode 100644 index 0000000..bb4bce5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_edu_briefcase.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_edu_calculator.xml b/resources/src/main/res/drawable/ic_vue_edu_calculator.xml new file mode 100644 index 0000000..4c8515d --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_edu_calculator.xml @@ -0,0 +1,62 @@ + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_edu_glass.xml b/resources/src/main/res/drawable/ic_vue_edu_glass.xml new file mode 100644 index 0000000..d056198 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_edu_glass.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_edu_graduate_cap.xml b/resources/src/main/res/drawable/ic_vue_edu_graduate_cap.xml new file mode 100644 index 0000000..798084a --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_edu_graduate_cap.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_edu_magazine.xml b/resources/src/main/res/drawable/ic_vue_edu_magazine.xml new file mode 100644 index 0000000..b7ac1cc --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_edu_magazine.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_edu_note.xml b/resources/src/main/res/drawable/ic_vue_edu_note.xml new file mode 100644 index 0000000..0b0f7a8 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_edu_note.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_edu_omega.xml b/resources/src/main/res/drawable/ic_vue_edu_omega.xml new file mode 100644 index 0000000..e848290 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_edu_omega.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_edu_pen.xml b/resources/src/main/res/drawable/ic_vue_edu_pen.xml new file mode 100644 index 0000000..ab1a990 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_edu_pen.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_edu_planer.xml b/resources/src/main/res/drawable/ic_vue_edu_planer.xml new file mode 100644 index 0000000..6f799b2 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_edu_planer.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_edu_ruler_pen.xml b/resources/src/main/res/drawable/ic_vue_edu_ruler_pen.xml new file mode 100644 index 0000000..e8382b0 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_edu_ruler_pen.xml @@ -0,0 +1,48 @@ + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_edu_telescope.xml b/resources/src/main/res/drawable/ic_vue_edu_telescope.xml new file mode 100644 index 0000000..ac561c7 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_edu_telescope.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_edu_todo.xml b/resources/src/main/res/drawable/ic_vue_edu_todo.xml new file mode 100644 index 0000000..7f20916 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_edu_todo.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_files_folder.xml b/resources/src/main/res/drawable/ic_vue_files_folder.xml new file mode 100644 index 0000000..73a62e3 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_files_folder.xml @@ -0,0 +1,11 @@ + + + diff --git a/resources/src/main/res/drawable/ic_vue_files_folder_cloud.xml b/resources/src/main/res/drawable/ic_vue_files_folder_cloud.xml new file mode 100644 index 0000000..ca45e11 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_files_folder_cloud.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_files_folder_favorite.xml b/resources/src/main/res/drawable/ic_vue_files_folder_favorite.xml new file mode 100644 index 0000000..a8d9669 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_files_folder_favorite.xml @@ -0,0 +1,18 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_location_discover.xml b/resources/src/main/res/drawable/ic_vue_location_discover.xml new file mode 100644 index 0000000..e2ca0f2 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_location_discover.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_location_global.xml b/resources/src/main/res/drawable/ic_vue_location_global.xml new file mode 100644 index 0000000..51ddc03 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_location_global.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_location_global_edit.xml b/resources/src/main/res/drawable/ic_vue_location_global_edit.xml new file mode 100644 index 0000000..85f8bd3 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_location_global_edit.xml @@ -0,0 +1,55 @@ + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_location_global_search.xml b/resources/src/main/res/drawable/ic_vue_location_global_search.xml new file mode 100644 index 0000000..3f7255a --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_location_global_search.xml @@ -0,0 +1,55 @@ + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_location_location.xml b/resources/src/main/res/drawable/ic_vue_location_location.xml new file mode 100644 index 0000000..21591fa --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_location_location.xml @@ -0,0 +1,16 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_location_map.xml b/resources/src/main/res/drawable/ic_vue_location_map.xml new file mode 100644 index 0000000..a5a14fd --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_location_map.xml @@ -0,0 +1,39 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_location_map1.xml b/resources/src/main/res/drawable/ic_vue_location_map1.xml new file mode 100644 index 0000000..2a1132a --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_location_map1.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_location_radar.xml b/resources/src/main/res/drawable/ic_vue_location_radar.xml new file mode 100644 index 0000000..071cc21 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_location_radar.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_location_routing.xml b/resources/src/main/res/drawable/ic_vue_location_routing.xml new file mode 100644 index 0000000..ce303a5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_location_routing.xml @@ -0,0 +1,37 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_archive.xml b/resources/src/main/res/drawable/ic_vue_main_archive.xml new file mode 100644 index 0000000..74ea744 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_archive.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_battery_charging.xml b/resources/src/main/res/drawable/ic_vue_main_battery_charging.xml new file mode 100644 index 0000000..76ce6cc --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_battery_charging.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_battery_half.xml b/resources/src/main/res/drawable/ic_vue_main_battery_half.xml new file mode 100644 index 0000000..b971547 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_battery_half.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_broom.xml b/resources/src/main/res/drawable/ic_vue_main_broom.xml new file mode 100644 index 0000000..abc1789 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_broom.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_cake.xml b/resources/src/main/res/drawable/ic_vue_main_cake.xml new file mode 100644 index 0000000..ac26fbc --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_cake.xml @@ -0,0 +1,55 @@ + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_calendar.xml b/resources/src/main/res/drawable/ic_vue_main_calendar.xml new file mode 100644 index 0000000..240d231 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_calendar.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_clock.xml b/resources/src/main/res/drawable/ic_vue_main_clock.xml new file mode 100644 index 0000000..ea47d51 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_clock.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_coffee.xml b/resources/src/main/res/drawable/ic_vue_main_coffee.xml new file mode 100644 index 0000000..be1a194 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_coffee.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_crown.xml b/resources/src/main/res/drawable/ic_vue_main_crown.xml new file mode 100644 index 0000000..b7f03ad --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_crown.xml @@ -0,0 +1,13 @@ + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_cup.xml b/resources/src/main/res/drawable/ic_vue_main_cup.xml new file mode 100644 index 0000000..cde6f45 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_cup.xml @@ -0,0 +1,46 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_emoji_happy.xml b/resources/src/main/res/drawable/ic_vue_main_emoji_happy.xml new file mode 100644 index 0000000..c5a3b5f --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_emoji_happy.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_emoji_normal.xml b/resources/src/main/res/drawable/ic_vue_main_emoji_normal.xml new file mode 100644 index 0000000..5b0fd11 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_emoji_normal.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_emoji_sad.xml b/resources/src/main/res/drawable/ic_vue_main_emoji_sad.xml new file mode 100644 index 0000000..07b3a39 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_emoji_sad.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_flash.xml b/resources/src/main/res/drawable/ic_vue_main_flash.xml new file mode 100644 index 0000000..a74ff93 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_flash.xml @@ -0,0 +1,13 @@ + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_gift.xml b/resources/src/main/res/drawable/ic_vue_main_gift.xml new file mode 100644 index 0000000..e5a660b --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_gift.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_glass.xml b/resources/src/main/res/drawable/ic_vue_main_glass.xml new file mode 100644 index 0000000..f1098c2 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_glass.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_home.xml b/resources/src/main/res/drawable/ic_vue_main_home.xml new file mode 100644 index 0000000..63b7a63 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_home.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_home_safe.xml b/resources/src/main/res/drawable/ic_vue_main_home_safe.xml new file mode 100644 index 0000000..f94217c --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_home_safe.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_home_wifi.xml b/resources/src/main/res/drawable/ic_vue_main_home_wifi.xml new file mode 100644 index 0000000..7e5bf0c --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_home_wifi.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_judge.xml b/resources/src/main/res/drawable/ic_vue_main_judge.xml new file mode 100644 index 0000000..1d9b17d --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_judge.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_lamp.xml b/resources/src/main/res/drawable/ic_vue_main_lamp.xml new file mode 100644 index 0000000..b951c29 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_lamp.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_lamp_charge.xml b/resources/src/main/res/drawable/ic_vue_main_lamp_charge.xml new file mode 100644 index 0000000..99ef648 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_lamp_charge.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_lifebuoy.xml b/resources/src/main/res/drawable/ic_vue_main_lifebuoy.xml new file mode 100644 index 0000000..e4c664e --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_lifebuoy.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_milk.xml b/resources/src/main/res/drawable/ic_vue_main_milk.xml new file mode 100644 index 0000000..fc15fa5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_milk.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_notification.xml b/resources/src/main/res/drawable/ic_vue_main_notification.xml new file mode 100644 index 0000000..f9ebec6 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_notification.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_pet.xml b/resources/src/main/res/drawable/ic_vue_main_pet.xml new file mode 100644 index 0000000..84ebee4 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_pet.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_reserve.xml b/resources/src/main/res/drawable/ic_vue_main_reserve.xml new file mode 100644 index 0000000..1f01b72 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_reserve.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_send.xml b/resources/src/main/res/drawable/ic_vue_main_send.xml new file mode 100644 index 0000000..1b7899b --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_send.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_share.xml b/resources/src/main/res/drawable/ic_vue_main_share.xml new file mode 100644 index 0000000..5f8efeb --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_share.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_signpost.xml b/resources/src/main/res/drawable/ic_vue_main_signpost.xml new file mode 100644 index 0000000..47a6cfb --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_signpost.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_sport.xml b/resources/src/main/res/drawable/ic_vue_main_sport.xml new file mode 100644 index 0000000..3377c89 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_sport.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_timer.xml b/resources/src/main/res/drawable/ic_vue_main_timer.xml new file mode 100644 index 0000000..8d2dd96 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_timer.xml @@ -0,0 +1,13 @@ + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_trash.xml b/resources/src/main/res/drawable/ic_vue_main_trash.xml new file mode 100644 index 0000000..fa99bb2 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_trash.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_main_tree.xml b/resources/src/main/res/drawable/ic_vue_main_tree.xml new file mode 100644 index 0000000..7539605 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_main_tree.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_media_camera.xml b/resources/src/main/res/drawable/ic_vue_media_camera.xml new file mode 100644 index 0000000..cbc551d --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_media_camera.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_media_film.xml b/resources/src/main/res/drawable/ic_vue_media_film.xml new file mode 100644 index 0000000..1f0e929 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_media_film.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_media_film_play.xml b/resources/src/main/res/drawable/ic_vue_media_film_play.xml new file mode 100644 index 0000000..467003b --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_media_film_play.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_media_image.xml b/resources/src/main/res/drawable/ic_vue_media_image.xml new file mode 100644 index 0000000..7ad2763 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_media_image.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_media_microphone.xml b/resources/src/main/res/drawable/ic_vue_media_microphone.xml new file mode 100644 index 0000000..7cdbff4 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_media_microphone.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_media_mountains.xml b/resources/src/main/res/drawable/ic_vue_media_mountains.xml new file mode 100644 index 0000000..e0caf1c --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_media_mountains.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_media_music.xml b/resources/src/main/res/drawable/ic_vue_media_music.xml new file mode 100644 index 0000000..e890846 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_media_music.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_media_photocamera.xml b/resources/src/main/res/drawable/ic_vue_media_photocamera.xml new file mode 100644 index 0000000..e75f904 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_media_photocamera.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_media_play.xml b/resources/src/main/res/drawable/ic_vue_media_play.xml new file mode 100644 index 0000000..aba6ba4 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_media_play.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_media_scissors.xml b/resources/src/main/res/drawable/ic_vue_media_scissors.xml new file mode 100644 index 0000000..6cf038a --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_media_scissors.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_media_screenmirroring.xml b/resources/src/main/res/drawable/ic_vue_media_screenmirroring.xml new file mode 100644 index 0000000..66028e1 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_media_screenmirroring.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_media_setting.xml b/resources/src/main/res/drawable/ic_vue_media_setting.xml new file mode 100644 index 0000000..348b0b8 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_media_setting.xml @@ -0,0 +1,55 @@ + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_media_speaker.xml b/resources/src/main/res/drawable/ic_vue_media_speaker.xml new file mode 100644 index 0000000..36e10ea --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_media_speaker.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_media_subtitle.xml b/resources/src/main/res/drawable/ic_vue_media_subtitle.xml new file mode 100644 index 0000000..3572203 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_media_subtitle.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_media_voice.xml b/resources/src/main/res/drawable/ic_vue_media_voice.xml new file mode 100644 index 0000000..6070753 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_media_voice.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_messages_device_msg.xml b/resources/src/main/res/drawable/ic_vue_messages_device_msg.xml new file mode 100644 index 0000000..3a87ead --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_messages_device_msg.xml @@ -0,0 +1,55 @@ + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_messages_direct.xml b/resources/src/main/res/drawable/ic_vue_messages_direct.xml new file mode 100644 index 0000000..2ba1f15 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_messages_direct.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_messages_edit.xml b/resources/src/main/res/drawable/ic_vue_messages_edit.xml new file mode 100644 index 0000000..362fd77 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_messages_edit.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_messages_letter.xml b/resources/src/main/res/drawable/ic_vue_messages_letter.xml new file mode 100644 index 0000000..fc15672 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_messages_letter.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_messages_msg.xml b/resources/src/main/res/drawable/ic_vue_messages_msg.xml new file mode 100644 index 0000000..7c0178c --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_messages_msg.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_messages_msg_favorite.xml b/resources/src/main/res/drawable/ic_vue_messages_msg_favorite.xml new file mode 100644 index 0000000..4e75309 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_messages_msg_favorite.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_messages_msg_notification.xml b/resources/src/main/res/drawable/ic_vue_messages_msg_notification.xml new file mode 100644 index 0000000..84dce13 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_messages_msg_notification.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_messages_msg_search.xml b/resources/src/main/res/drawable/ic_vue_messages_msg_search.xml new file mode 100644 index 0000000..82a2627 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_messages_msg_search.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_messages_msg_text.xml b/resources/src/main/res/drawable/ic_vue_messages_msg_text.xml new file mode 100644 index 0000000..1368b8a --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_messages_msg_text.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_messages_msgs.xml b/resources/src/main/res/drawable/ic_vue_messages_msgs.xml new file mode 100644 index 0000000..a9de213 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_messages_msgs.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_archive.xml b/resources/src/main/res/drawable/ic_vue_money_archive.xml new file mode 100644 index 0000000..61ee428 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_archive.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_bitcoin_refresh.xml b/resources/src/main/res/drawable/ic_vue_money_bitcoin_refresh.xml new file mode 100644 index 0000000..6e947ca --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_bitcoin_refresh.xml @@ -0,0 +1,62 @@ + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_buy_bitcoin.xml b/resources/src/main/res/drawable/ic_vue_money_buy_bitcoin.xml new file mode 100644 index 0000000..d6e42b6 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_buy_bitcoin.xml @@ -0,0 +1,62 @@ + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_buy_crypto.xml b/resources/src/main/res/drawable/ic_vue_money_buy_crypto.xml new file mode 100644 index 0000000..13c6e5a --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_buy_crypto.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_card.xml b/resources/src/main/res/drawable/ic_vue_money_card.xml new file mode 100644 index 0000000..e563042 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_card.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_card_bitcoin.xml b/resources/src/main/res/drawable/ic_vue_money_card_bitcoin.xml new file mode 100644 index 0000000..3bc539b --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_card_bitcoin.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_card_coin.xml b/resources/src/main/res/drawable/ic_vue_money_card_coin.xml new file mode 100644 index 0000000..924ed9e --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_card_coin.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_card_receive.xml b/resources/src/main/res/drawable/ic_vue_money_card_receive.xml new file mode 100644 index 0000000..4bec896 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_card_receive.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_card_send.xml b/resources/src/main/res/drawable/ic_vue_money_card_send.xml new file mode 100644 index 0000000..c991eee --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_card_send.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_coins.xml b/resources/src/main/res/drawable/ic_vue_money_coins.xml new file mode 100644 index 0000000..a91821d --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_coins.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_discount.xml b/resources/src/main/res/drawable/ic_vue_money_discount.xml new file mode 100644 index 0000000..b471a61 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_discount.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_dollar.xml b/resources/src/main/res/drawable/ic_vue_money_dollar.xml new file mode 100644 index 0000000..40dc45b --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_dollar.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_math.xml b/resources/src/main/res/drawable/ic_vue_money_math.xml new file mode 100644 index 0000000..157e6b0 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_math.xml @@ -0,0 +1,55 @@ + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_percentage.xml b/resources/src/main/res/drawable/ic_vue_money_percentage.xml new file mode 100644 index 0000000..b0f800d --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_percentage.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_receipt_discount.xml b/resources/src/main/res/drawable/ic_vue_money_receipt_discount.xml new file mode 100644 index 0000000..bd46744 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_receipt_discount.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_receipt_empty.xml b/resources/src/main/res/drawable/ic_vue_money_receipt_empty.xml new file mode 100644 index 0000000..3031206 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_receipt_empty.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_receipt_items.xml b/resources/src/main/res/drawable/ic_vue_money_receipt_items.xml new file mode 100644 index 0000000..b7bd4f9 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_receipt_items.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_recive.xml b/resources/src/main/res/drawable/ic_vue_money_recive.xml new file mode 100644 index 0000000..0297f75 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_recive.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_security_card.xml b/resources/src/main/res/drawable/ic_vue_money_security_card.xml new file mode 100644 index 0000000..8f405cc --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_security_card.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_send.xml b/resources/src/main/res/drawable/ic_vue_money_send.xml new file mode 100644 index 0000000..45bb230 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_send.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_tag.xml b/resources/src/main/res/drawable/ic_vue_money_tag.xml new file mode 100644 index 0000000..414dfea --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_tag.xml @@ -0,0 +1,19 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_ticket.xml b/resources/src/main/res/drawable/ic_vue_money_ticket.xml new file mode 100644 index 0000000..af6271a --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_ticket.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_ticket_discount.xml b/resources/src/main/res/drawable/ic_vue_money_ticket_discount.xml new file mode 100644 index 0000000..8fab802 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_ticket_discount.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_ticket_star.xml b/resources/src/main/res/drawable/ic_vue_money_ticket_star.xml new file mode 100644 index 0000000..2600887 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_ticket_star.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_transfer.xml b/resources/src/main/res/drawable/ic_vue_money_transfer.xml new file mode 100644 index 0000000..f8f902f --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_transfer.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_wallet.xml b/resources/src/main/res/drawable/ic_vue_money_wallet.xml new file mode 100644 index 0000000..a479d3f --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_wallet.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_wallet_cards.xml b/resources/src/main/res/drawable/ic_vue_money_wallet_cards.xml new file mode 100644 index 0000000..f20dcf9 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_wallet_cards.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_wallet_empty.xml b/resources/src/main/res/drawable/ic_vue_money_wallet_empty.xml new file mode 100644 index 0000000..154689b --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_wallet_empty.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_money_wallet_money.xml b/resources/src/main/res/drawable/ic_vue_money_wallet_money.xml new file mode 100644 index 0000000..9bd68b5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_money_wallet_money.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_pc_bluetooth.xml b/resources/src/main/res/drawable/ic_vue_pc_bluetooth.xml new file mode 100644 index 0000000..75e48ce --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_pc_bluetooth.xml @@ -0,0 +1,13 @@ + + + diff --git a/resources/src/main/res/drawable/ic_vue_pc_charging.xml b/resources/src/main/res/drawable/ic_vue_pc_charging.xml new file mode 100644 index 0000000..fdc014e --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_pc_charging.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_pc_cpu.xml b/resources/src/main/res/drawable/ic_vue_pc_cpu.xml new file mode 100644 index 0000000..a028e50 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_pc_cpu.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_pc_game.xml b/resources/src/main/res/drawable/ic_vue_pc_game.xml new file mode 100644 index 0000000..3feb70e --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_pc_game.xml @@ -0,0 +1,62 @@ + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_pc_gameboy.xml b/resources/src/main/res/drawable/ic_vue_pc_gameboy.xml new file mode 100644 index 0000000..3deef10 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_pc_gameboy.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_pc_headphone.xml b/resources/src/main/res/drawable/ic_vue_pc_headphone.xml new file mode 100644 index 0000000..455ea0a --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_pc_headphone.xml @@ -0,0 +1,13 @@ + + + diff --git a/resources/src/main/res/drawable/ic_vue_pc_monitor.xml b/resources/src/main/res/drawable/ic_vue_pc_monitor.xml new file mode 100644 index 0000000..24c3f36 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_pc_monitor.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_pc_phone.xml b/resources/src/main/res/drawable/ic_vue_pc_phone.xml new file mode 100644 index 0000000..9ea686b --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_pc_phone.xml @@ -0,0 +1,11 @@ + + + diff --git a/resources/src/main/res/drawable/ic_vue_pc_phone_call.xml b/resources/src/main/res/drawable/ic_vue_pc_phone_call.xml new file mode 100644 index 0000000..7723ecb --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_pc_phone_call.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_pc_printer.xml b/resources/src/main/res/drawable/ic_vue_pc_printer.xml new file mode 100644 index 0000000..b492aaa --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_pc_printer.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_pc_setting.xml b/resources/src/main/res/drawable/ic_vue_pc_setting.xml new file mode 100644 index 0000000..a639e1b --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_pc_setting.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_pc_speaker.xml b/resources/src/main/res/drawable/ic_vue_pc_speaker.xml new file mode 100644 index 0000000..89cba13 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_pc_speaker.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_pc_watch.xml b/resources/src/main/res/drawable/ic_vue_pc_watch.xml new file mode 100644 index 0000000..4b705f2 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_pc_watch.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_pc_wifi.xml b/resources/src/main/res/drawable/ic_vue_pc_wifi.xml new file mode 100644 index 0000000..17fdaac --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_pc_wifi.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_people_2persons.xml b/resources/src/main/res/drawable/ic_vue_people_2persons.xml new file mode 100644 index 0000000..cbe6bc9 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_people_2persons.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_people_people.xml b/resources/src/main/res/drawable/ic_vue_people_people.xml new file mode 100644 index 0000000..e65050a --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_people_people.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_people_person.xml b/resources/src/main/res/drawable/ic_vue_people_person.xml new file mode 100644 index 0000000..2415da0 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_people_person.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_people_person_search.xml b/resources/src/main/res/drawable/ic_vue_people_person_search.xml new file mode 100644 index 0000000..7a9af2f --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_people_person_search.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_people_person_tag.xml b/resources/src/main/res/drawable/ic_vue_people_person_tag.xml new file mode 100644 index 0000000..32868c3 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_people_person_tag.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_security_alarm.xml b/resources/src/main/res/drawable/ic_vue_security_alarm.xml new file mode 100644 index 0000000..37e673a --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_security_alarm.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_security_eye.xml b/resources/src/main/res/drawable/ic_vue_security_eye.xml new file mode 100644 index 0000000..2f244bf --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_security_eye.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_security_key.xml b/resources/src/main/res/drawable/ic_vue_security_key.xml new file mode 100644 index 0000000..5a536f0 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_security_key.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_security_lock.xml b/resources/src/main/res/drawable/ic_vue_security_lock.xml new file mode 100644 index 0000000..88acd71 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_security_lock.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_security_password.xml b/resources/src/main/res/drawable/ic_vue_security_password.xml new file mode 100644 index 0000000..27df61f --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_security_password.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_security_radar.xml b/resources/src/main/res/drawable/ic_vue_security_radar.xml new file mode 100644 index 0000000..278bde6 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_security_radar.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_security_shield.xml b/resources/src/main/res/drawable/ic_vue_security_shield.xml new file mode 100644 index 0000000..f98f4a5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_security_shield.xml @@ -0,0 +1,13 @@ + + + diff --git a/resources/src/main/res/drawable/ic_vue_security_shield_person.xml b/resources/src/main/res/drawable/ic_vue_security_shield_person.xml new file mode 100644 index 0000000..0bd9a65 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_security_shield_person.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_security_shield_security.xml b/resources/src/main/res/drawable/ic_vue_security_shield_security.xml new file mode 100644 index 0000000..4ee030c --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_security_shield_security.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_shop_bag.xml b/resources/src/main/res/drawable/ic_vue_shop_bag.xml new file mode 100644 index 0000000..8801fbf --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_shop_bag.xml @@ -0,0 +1,43 @@ + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_shop_bag1.xml b/resources/src/main/res/drawable/ic_vue_shop_bag1.xml new file mode 100644 index 0000000..5818415 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_shop_bag1.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_shop_barcode.xml b/resources/src/main/res/drawable/ic_vue_shop_barcode.xml new file mode 100644 index 0000000..7414bc8 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_shop_barcode.xml @@ -0,0 +1,62 @@ + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_shop_cart.xml b/resources/src/main/res/drawable/ic_vue_shop_cart.xml new file mode 100644 index 0000000..f981b8c --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_shop_cart.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_shop_shop.xml b/resources/src/main/res/drawable/ic_vue_shop_shop.xml new file mode 100644 index 0000000..0051e5a --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_shop_shop.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_support_dislike.xml b/resources/src/main/res/drawable/ic_vue_support_dislike.xml new file mode 100644 index 0000000..6fff397 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_support_dislike.xml @@ -0,0 +1,18 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_support_heart.xml b/resources/src/main/res/drawable/ic_vue_support_heart.xml new file mode 100644 index 0000000..c9ebf09 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_support_heart.xml @@ -0,0 +1,13 @@ + + + diff --git a/resources/src/main/res/drawable/ic_vue_support_like.xml b/resources/src/main/res/drawable/ic_vue_support_like.xml new file mode 100644 index 0000000..8f092bb --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_support_like.xml @@ -0,0 +1,18 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_support_like_dislike.xml b/resources/src/main/res/drawable/ic_vue_support_like_dislike.xml new file mode 100644 index 0000000..1eb3b86 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_support_like_dislike.xml @@ -0,0 +1,30 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_support_medal.xml b/resources/src/main/res/drawable/ic_vue_support_medal.xml new file mode 100644 index 0000000..fe1fe99 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_support_medal.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_support_smileys.xml b/resources/src/main/res/drawable/ic_vue_support_smileys.xml new file mode 100644 index 0000000..e9252b1 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_support_smileys.xml @@ -0,0 +1,62 @@ + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_support_star.xml b/resources/src/main/res/drawable/ic_vue_support_star.xml new file mode 100644 index 0000000..786bb3b --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_support_star.xml @@ -0,0 +1,13 @@ + + + diff --git a/resources/src/main/res/drawable/ic_vue_transport_airplane.xml b/resources/src/main/res/drawable/ic_vue_transport_airplane.xml new file mode 100644 index 0000000..e8ec64b --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_transport_airplane.xml @@ -0,0 +1,13 @@ + + + diff --git a/resources/src/main/res/drawable/ic_vue_transport_bus.xml b/resources/src/main/res/drawable/ic_vue_transport_bus.xml new file mode 100644 index 0000000..acd9972 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_transport_bus.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_transport_car.xml b/resources/src/main/res/drawable/ic_vue_transport_car.xml new file mode 100644 index 0000000..e675475 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_transport_car.xml @@ -0,0 +1,62 @@ + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_transport_car_wash.xml b/resources/src/main/res/drawable/ic_vue_transport_car_wash.xml new file mode 100644 index 0000000..cca8b11 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_transport_car_wash.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_transport_gas.xml b/resources/src/main/res/drawable/ic_vue_transport_gas.xml new file mode 100644 index 0000000..64e5172 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_transport_gas.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_transport_ship.xml b/resources/src/main/res/drawable/ic_vue_transport_ship.xml new file mode 100644 index 0000000..23dbf48 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_transport_ship.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_transport_train.xml b/resources/src/main/res/drawable/ic_vue_transport_train.xml new file mode 100644 index 0000000..0f775fb --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_transport_train.xml @@ -0,0 +1,62 @@ + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_type_link.xml b/resources/src/main/res/drawable/ic_vue_type_link.xml new file mode 100644 index 0000000..170b86d --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_type_link.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_type_link2.xml b/resources/src/main/res/drawable/ic_vue_type_link2.xml new file mode 100644 index 0000000..a4900cd --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_type_link2.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_type_paperclip.xml b/resources/src/main/res/drawable/ic_vue_type_paperclip.xml new file mode 100644 index 0000000..1794245 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_type_paperclip.xml @@ -0,0 +1,13 @@ + + + diff --git a/resources/src/main/res/drawable/ic_vue_type_text.xml b/resources/src/main/res/drawable/ic_vue_type_text.xml new file mode 100644 index 0000000..9d6885f --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_type_text.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_type_textalign_center.xml b/resources/src/main/res/drawable/ic_vue_type_textalign_center.xml new file mode 100644 index 0000000..a7ed01a --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_type_textalign_center.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_type_textalign_justifycenter.xml b/resources/src/main/res/drawable/ic_vue_type_textalign_justifycenter.xml new file mode 100644 index 0000000..14dc4ce --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_type_textalign_justifycenter.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_type_textalign_left.xml b/resources/src/main/res/drawable/ic_vue_type_textalign_left.xml new file mode 100644 index 0000000..1876774 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_type_textalign_left.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_type_textalign_right.xml b/resources/src/main/res/drawable/ic_vue_type_textalign_right.xml new file mode 100644 index 0000000..6126eb1 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_type_textalign_right.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_type_translate.xml b/resources/src/main/res/drawable/ic_vue_type_translate.xml new file mode 100644 index 0000000..d5f1a7b --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_type_translate.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_weather_cloud.xml b/resources/src/main/res/drawable/ic_vue_weather_cloud.xml new file mode 100644 index 0000000..f49806d --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_weather_cloud.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_weather_cold.xml b/resources/src/main/res/drawable/ic_vue_weather_cold.xml new file mode 100644 index 0000000..7e6ae4c --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_weather_cold.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_weather_drop.xml b/resources/src/main/res/drawable/ic_vue_weather_drop.xml new file mode 100644 index 0000000..ae1a0e5 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_weather_drop.xml @@ -0,0 +1,11 @@ + + + diff --git a/resources/src/main/res/drawable/ic_vue_weather_flash.xml b/resources/src/main/res/drawable/ic_vue_weather_flash.xml new file mode 100644 index 0000000..68c7f22 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_weather_flash.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_vue_weather_moon.xml b/resources/src/main/res/drawable/ic_vue_weather_moon.xml new file mode 100644 index 0000000..c58367e --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_weather_moon.xml @@ -0,0 +1,13 @@ + + + diff --git a/resources/src/main/res/drawable/ic_vue_weather_sun.xml b/resources/src/main/res/drawable/ic_vue_weather_sun.xml new file mode 100644 index 0000000..78ea0c7 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_weather_sun.xml @@ -0,0 +1,20 @@ + + + + diff --git a/resources/src/main/res/drawable/ic_vue_weather_wind.xml b/resources/src/main/res/drawable/ic_vue_weather_wind.xml new file mode 100644 index 0000000..3296fc9 --- /dev/null +++ b/resources/src/main/res/drawable/ic_vue_weather_wind.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/src/main/res/drawable/ic_wallet.xml b/resources/src/main/res/drawable/ic_wallet.xml new file mode 100644 index 0000000..6112189 --- /dev/null +++ b/resources/src/main/res/drawable/ic_wallet.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/ic_widget_expense.xml b/resources/src/main/res/drawable/ic_widget_expense.xml new file mode 100644 index 0000000..889a181 --- /dev/null +++ b/resources/src/main/res/drawable/ic_widget_expense.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/resources/src/main/res/drawable/ic_widget_income.xml b/resources/src/main/res/drawable/ic_widget_income.xml new file mode 100644 index 0000000..fb0dcd2 --- /dev/null +++ b/resources/src/main/res/drawable/ic_widget_income.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_widget_transfer.xml b/resources/src/main/res/drawable/ic_widget_transfer.xml new file mode 100644 index 0000000..c60f23b --- /dev/null +++ b/resources/src/main/res/drawable/ic_widget_transfer.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ic_wishlist.xml b/resources/src/main/res/drawable/ic_wishlist.xml new file mode 100644 index 0000000..ed816ba --- /dev/null +++ b/resources/src/main/res/drawable/ic_wishlist.xml @@ -0,0 +1,12 @@ + + + + diff --git a/resources/src/main/res/drawable/income_shape_widget_backgroud.xml b/resources/src/main/res/drawable/income_shape_widget_backgroud.xml new file mode 100644 index 0000000..24960a7 --- /dev/null +++ b/resources/src/main/res/drawable/income_shape_widget_backgroud.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/resources/src/main/res/drawable/ivy_wallet_logo.xml b/resources/src/main/res/drawable/ivy_wallet_logo.xml new file mode 100644 index 0000000..70e0016 --- /dev/null +++ b/resources/src/main/res/drawable/ivy_wallet_logo.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/ktw_money_manager_logo.png b/resources/src/main/res/drawable/ktw_money_manager_logo.png new file mode 100644 index 0000000..f73bba3 Binary files /dev/null and b/resources/src/main/res/drawable/ktw_money_manager_logo.png differ diff --git a/resources/src/main/res/drawable/no_account_illustration.xml b/resources/src/main/res/drawable/no_account_illustration.xml new file mode 100644 index 0000000..9d7113e --- /dev/null +++ b/resources/src/main/res/drawable/no_account_illustration.xml @@ -0,0 +1,325 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/no_account_illustration_dark.xml b/resources/src/main/res/drawable/no_account_illustration_dark.xml new file mode 100644 index 0000000..e0d8830 --- /dev/null +++ b/resources/src/main/res/drawable/no_account_illustration_dark.xml @@ -0,0 +1,325 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/src/main/res/drawable/onboarding_illustration_accounts.png b/resources/src/main/res/drawable/onboarding_illustration_accounts.png new file mode 100644 index 0000000..db95595 Binary files /dev/null and b/resources/src/main/res/drawable/onboarding_illustration_accounts.png differ diff --git a/resources/src/main/res/drawable/onboarding_illustration_categories.png b/resources/src/main/res/drawable/onboarding_illustration_categories.png new file mode 100644 index 0000000..36c4962 Binary files /dev/null and b/resources/src/main/res/drawable/onboarding_illustration_categories.png differ diff --git a/resources/src/main/res/drawable/onboarding_illustration_import.png b/resources/src/main/res/drawable/onboarding_illustration_import.png new file mode 100644 index 0000000..d567231 Binary files /dev/null and b/resources/src/main/res/drawable/onboarding_illustration_import.png differ diff --git a/resources/src/main/res/drawable/one_money_logo.png b/resources/src/main/res/drawable/one_money_logo.png new file mode 100644 index 0000000..70e824d Binary files /dev/null and b/resources/src/main/res/drawable/one_money_logo.png differ diff --git a/resources/src/main/res/drawable/outline_backspace_24.xml b/resources/src/main/res/drawable/outline_backspace_24.xml new file mode 100644 index 0000000..9ebe935 --- /dev/null +++ b/resources/src/main/res/drawable/outline_backspace_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/resources/src/main/res/drawable/outline_color_lens_24.xml b/resources/src/main/res/drawable/outline_color_lens_24.xml new file mode 100644 index 0000000..ecf060c --- /dev/null +++ b/resources/src/main/res/drawable/outline_color_lens_24.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/resources/src/main/res/drawable/outline_delete_24.xml b/resources/src/main/res/drawable/outline_delete_24.xml new file mode 100644 index 0000000..9196eea --- /dev/null +++ b/resources/src/main/res/drawable/outline_delete_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/outline_info_24.xml b/resources/src/main/res/drawable/outline_info_24.xml new file mode 100644 index 0000000..caacb83 --- /dev/null +++ b/resources/src/main/res/drawable/outline_info_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/preview_widget_add_trn.png b/resources/src/main/res/drawable/preview_widget_add_trn.png new file mode 100644 index 0000000..6f73752 Binary files /dev/null and b/resources/src/main/res/drawable/preview_widget_add_trn.png differ diff --git a/resources/src/main/res/drawable/preview_widget_add_trn_compact.png b/resources/src/main/res/drawable/preview_widget_add_trn_compact.png new file mode 100644 index 0000000..d0e87e1 Binary files /dev/null and b/resources/src/main/res/drawable/preview_widget_add_trn_compact.png differ diff --git a/resources/src/main/res/drawable/preview_widget_wallet_balance.png b/resources/src/main/res/drawable/preview_widget_wallet_balance.png new file mode 100644 index 0000000..1ff4163 Binary files /dev/null and b/resources/src/main/res/drawable/preview_widget_wallet_balance.png differ diff --git a/resources/src/main/res/drawable/questions.png b/resources/src/main/res/drawable/questions.png new file mode 100644 index 0000000..bb36258 Binary files /dev/null and b/resources/src/main/res/drawable/questions.png differ diff --git a/resources/src/main/res/drawable/round_archive_24.xml b/resources/src/main/res/drawable/round_archive_24.xml new file mode 100644 index 0000000..5c29e42 --- /dev/null +++ b/resources/src/main/res/drawable/round_archive_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/round_arrow_back_ios_24.xml b/resources/src/main/res/drawable/round_arrow_back_ios_24.xml new file mode 100644 index 0000000..2dbf9d0 --- /dev/null +++ b/resources/src/main/res/drawable/round_arrow_back_ios_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/resources/src/main/res/drawable/round_currency_exchange_24.xml b/resources/src/main/res/drawable/round_currency_exchange_24.xml new file mode 100644 index 0000000..5be5fa1 --- /dev/null +++ b/resources/src/main/res/drawable/round_currency_exchange_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/round_done_24.xml b/resources/src/main/res/drawable/round_done_24.xml new file mode 100644 index 0000000..e620ad8 --- /dev/null +++ b/resources/src/main/res/drawable/round_done_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/round_expand_more_24.xml b/resources/src/main/res/drawable/round_expand_more_24.xml new file mode 100644 index 0000000..989203b --- /dev/null +++ b/resources/src/main/res/drawable/round_expand_more_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/round_remove_24.xml b/resources/src/main/res/drawable/round_remove_24.xml new file mode 100644 index 0000000..7d2baa8 --- /dev/null +++ b/resources/src/main/res/drawable/round_remove_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/round_reorder_24.xml b/resources/src/main/res/drawable/round_reorder_24.xml new file mode 100644 index 0000000..f077e12 --- /dev/null +++ b/resources/src/main/res/drawable/round_reorder_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/round_search_24.xml b/resources/src/main/res/drawable/round_search_24.xml new file mode 100644 index 0000000..1323e7b --- /dev/null +++ b/resources/src/main/res/drawable/round_search_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/round_search_off_24.xml b/resources/src/main/res/drawable/round_search_off_24.xml new file mode 100644 index 0000000..d6cb08d --- /dev/null +++ b/resources/src/main/res/drawable/round_search_off_24.xml @@ -0,0 +1,13 @@ + + + + diff --git a/resources/src/main/res/drawable/round_time_24.xml b/resources/src/main/res/drawable/round_time_24.xml new file mode 100644 index 0000000..f71c61b --- /dev/null +++ b/resources/src/main/res/drawable/round_time_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/round_unarchive_24.xml b/resources/src/main/res/drawable/round_unarchive_24.xml new file mode 100644 index 0000000..9a5ea5d --- /dev/null +++ b/resources/src/main/res/drawable/round_unarchive_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/resources/src/main/res/drawable/shape_widget_background.xml b/resources/src/main/res/drawable/shape_widget_background.xml new file mode 100644 index 0000000..bfa06b5 --- /dev/null +++ b/resources/src/main/res/drawable/shape_widget_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/resources/src/main/res/font/opensans_bold.ttf b/resources/src/main/res/font/opensans_bold.ttf new file mode 100644 index 0000000..efdd5e8 Binary files /dev/null and b/resources/src/main/res/font/opensans_bold.ttf differ diff --git a/resources/src/main/res/font/opensans_bolditalic.ttf b/resources/src/main/res/font/opensans_bolditalic.ttf new file mode 100644 index 0000000..9bf9b4e Binary files /dev/null and b/resources/src/main/res/font/opensans_bolditalic.ttf differ diff --git a/resources/src/main/res/font/opensans_extrabold.ttf b/resources/src/main/res/font/opensans_extrabold.ttf new file mode 100644 index 0000000..67fcf0f Binary files /dev/null and b/resources/src/main/res/font/opensans_extrabold.ttf differ diff --git a/resources/src/main/res/font/opensans_extrabolditalic.ttf b/resources/src/main/res/font/opensans_extrabolditalic.ttf new file mode 100644 index 0000000..0867228 Binary files /dev/null and b/resources/src/main/res/font/opensans_extrabolditalic.ttf differ diff --git a/resources/src/main/res/font/opensans_italic.ttf b/resources/src/main/res/font/opensans_italic.ttf new file mode 100644 index 0000000..1178567 Binary files /dev/null and b/resources/src/main/res/font/opensans_italic.ttf differ diff --git a/resources/src/main/res/font/opensans_light.ttf b/resources/src/main/res/font/opensans_light.ttf new file mode 100644 index 0000000..6580d3a Binary files /dev/null and b/resources/src/main/res/font/opensans_light.ttf differ diff --git a/resources/src/main/res/font/opensans_lightitalic.ttf b/resources/src/main/res/font/opensans_lightitalic.ttf new file mode 100644 index 0000000..1e0c331 Binary files /dev/null and b/resources/src/main/res/font/opensans_lightitalic.ttf differ diff --git a/resources/src/main/res/font/opensans_regular.ttf b/resources/src/main/res/font/opensans_regular.ttf new file mode 100644 index 0000000..29bfd35 Binary files /dev/null and b/resources/src/main/res/font/opensans_regular.ttf differ diff --git a/resources/src/main/res/font/opensans_semibold.ttf b/resources/src/main/res/font/opensans_semibold.ttf new file mode 100644 index 0000000..54e7059 Binary files /dev/null and b/resources/src/main/res/font/opensans_semibold.ttf differ diff --git a/resources/src/main/res/font/opensans_semibolditalic.ttf b/resources/src/main/res/font/opensans_semibolditalic.ttf new file mode 100644 index 0000000..aebcf14 Binary files /dev/null and b/resources/src/main/res/font/opensans_semibolditalic.ttf differ diff --git a/resources/src/main/res/font/raleway_black.ttf b/resources/src/main/res/font/raleway_black.ttf new file mode 100644 index 0000000..03e4d80 Binary files /dev/null and b/resources/src/main/res/font/raleway_black.ttf differ diff --git a/resources/src/main/res/font/raleway_blackitalic.ttf b/resources/src/main/res/font/raleway_blackitalic.ttf new file mode 100644 index 0000000..130434e Binary files /dev/null and b/resources/src/main/res/font/raleway_blackitalic.ttf differ diff --git a/resources/src/main/res/font/raleway_bold.ttf b/resources/src/main/res/font/raleway_bold.ttf new file mode 100644 index 0000000..8763231 Binary files /dev/null and b/resources/src/main/res/font/raleway_bold.ttf differ diff --git a/resources/src/main/res/font/raleway_bolditalic.ttf b/resources/src/main/res/font/raleway_bolditalic.ttf new file mode 100644 index 0000000..9c16440 Binary files /dev/null and b/resources/src/main/res/font/raleway_bolditalic.ttf differ diff --git a/resources/src/main/res/font/raleway_extrabold.ttf b/resources/src/main/res/font/raleway_extrabold.ttf new file mode 100644 index 0000000..64928cd Binary files /dev/null and b/resources/src/main/res/font/raleway_extrabold.ttf differ diff --git a/resources/src/main/res/font/raleway_extrabolditalic.ttf b/resources/src/main/res/font/raleway_extrabolditalic.ttf new file mode 100644 index 0000000..3728328 Binary files /dev/null and b/resources/src/main/res/font/raleway_extrabolditalic.ttf differ diff --git a/resources/src/main/res/font/raleway_extralight.ttf b/resources/src/main/res/font/raleway_extralight.ttf new file mode 100644 index 0000000..99a3abb Binary files /dev/null and b/resources/src/main/res/font/raleway_extralight.ttf differ diff --git a/resources/src/main/res/font/raleway_extralightitalic.ttf b/resources/src/main/res/font/raleway_extralightitalic.ttf new file mode 100644 index 0000000..1931863 Binary files /dev/null and b/resources/src/main/res/font/raleway_extralightitalic.ttf differ diff --git a/resources/src/main/res/font/raleway_italic.ttf b/resources/src/main/res/font/raleway_italic.ttf new file mode 100644 index 0000000..7bca5ad Binary files /dev/null and b/resources/src/main/res/font/raleway_italic.ttf differ diff --git a/resources/src/main/res/font/raleway_light.ttf b/resources/src/main/res/font/raleway_light.ttf new file mode 100644 index 0000000..43aa156 Binary files /dev/null and b/resources/src/main/res/font/raleway_light.ttf differ diff --git a/resources/src/main/res/font/raleway_lightitalic.ttf b/resources/src/main/res/font/raleway_lightitalic.ttf new file mode 100644 index 0000000..d88c549 Binary files /dev/null and b/resources/src/main/res/font/raleway_lightitalic.ttf differ diff --git a/resources/src/main/res/font/raleway_medium.ttf b/resources/src/main/res/font/raleway_medium.ttf new file mode 100644 index 0000000..5428c9c Binary files /dev/null and b/resources/src/main/res/font/raleway_medium.ttf differ diff --git a/resources/src/main/res/font/raleway_mediumitalic.ttf b/resources/src/main/res/font/raleway_mediumitalic.ttf new file mode 100644 index 0000000..ba0598a Binary files /dev/null and b/resources/src/main/res/font/raleway_mediumitalic.ttf differ diff --git a/resources/src/main/res/font/raleway_regular.ttf b/resources/src/main/res/font/raleway_regular.ttf new file mode 100644 index 0000000..acb5715 Binary files /dev/null and b/resources/src/main/res/font/raleway_regular.ttf differ diff --git a/resources/src/main/res/font/raleway_semibold.ttf b/resources/src/main/res/font/raleway_semibold.ttf new file mode 100644 index 0000000..6de54f7 Binary files /dev/null and b/resources/src/main/res/font/raleway_semibold.ttf differ diff --git a/resources/src/main/res/font/raleway_semibolditalic.ttf b/resources/src/main/res/font/raleway_semibolditalic.ttf new file mode 100644 index 0000000..9555af6 Binary files /dev/null and b/resources/src/main/res/font/raleway_semibolditalic.ttf differ diff --git a/resources/src/main/res/font/raleway_thin.ttf b/resources/src/main/res/font/raleway_thin.ttf new file mode 100644 index 0000000..b8e542a Binary files /dev/null and b/resources/src/main/res/font/raleway_thin.ttf differ diff --git a/resources/src/main/res/font/raleway_thinitalic.ttf b/resources/src/main/res/font/raleway_thinitalic.ttf new file mode 100644 index 0000000..7f0c0e9 Binary files /dev/null and b/resources/src/main/res/font/raleway_thinitalic.ttf differ diff --git a/resources/src/main/res/values-ar/strings.xml b/resources/src/main/res/values-ar/strings.xml new file mode 100644 index 0000000..b394312 --- /dev/null +++ b/resources/src/main/res/values-ar/strings.xml @@ -0,0 +1,450 @@ + + + الحسابات + %1$s المجموع: %2$s + الدخل هذا الشهر + الإنفاق هذا الشهر + (مستبعد) + دخل + إنفاق + التطبيق مغلق + الغِ القفل لدخول التطبيق + إلغاء القفل + الرصيد الحالي + الرصيد بعد الدفعات المحددة + اتصل + مزامنة المعاملات + يتم مزامنة المعاملات… + مزامنة البنك مفعلة: + حذف العميل + أضف ميزانية + لا يوجد ميزانيات + لا يوجد لديك اي ميزانيات محددة. اضغط على "أضف ميزانية" لإضافة واحدة. + الميزانيات + للفئات %1$s %2$s + %1$s %2$s ميزانية التطبيق + معلومات الميزانية: %1$s / %2$s + %2$s معلومات الميزانية: %1$s + أضف فئة + الإنفاق + عدد الإنفاق + الدخل + عدد المدخولات + مخطط الرصيد + الرصيد %1$s + المخططات + المدة: + الفئات + csv تصدير ملف + تصدير ملف csv بالخيارات القياسية + الرجاء استخدام الخيارات القياسية و التأكد من وجود الرأس + كيفية الإستيراد + افتح + الخطوات + تعليمات + فيديو + اقرأ المقال + CSV رفع ملف + تصدير البيانات + CSV/ZIP رفع ملف + التصدير الى ملف + مجموعة الحروف: UTF-8\nفاصلة الارقام: Decimal point \'.\'\nمحدد الحروف: فاصلة \'\ط\\,\' + Excel تصدير ملف + CSV حول الملف الى صيغة + ملاحظة: اذا كان المف المصدر لا ينتهي ب ,xls. اضفها عنة طريق اعادة نسمية الملف يدويا. + محول مجاني لملفات CSV + تحقق في مجلدات "االترويج" و "الرسائل الغير مرغوبة" في بريدك الالكتروني + حمل الملف المرفق في بريدك الالكتروني المحتوي على النص: \"transactions_export…\" + إذا كان لديك أكثر من عملة يجب عليك ان تنزل كل ملف ة تقوم بإيراده في التطيق. + الاستيراد من + الرجاء الإنتظار + يتم استيراد ملف الcsv + تم بنجاح + فشل + تم الإستيراد + %1$d معاملات + %1$d حسابات + %1$d فئات + فشل + %1$d صفوف من الملف لم يتعرف عليهم + الإنهاء + إضافة وصف + الوصف + مخطط في + إضافة المال الى + الدفع ب + من + الحساب + الى + إضافة حساب + عنوان الدخل + عنوان الإنفاق + عنوان التحويل + إنفاق + أضف تاريخ المحدد للدفع + ادفع + اجلب + تأكيد الحذف + حذف هذه العملية سيؤدي الى حذفها من تاريخ العمليات و تحديث الرصيد وفقاً لذلك. + تأكيد تغيير الحساب + ملاحظة: انت تحاول ان تغير حساب مرتبط بقرض من حساب بعملة مختلفة, \n سجلات القرض سوف يتم اعادة حسابها وفقا لأسعار الصرف اليوم + تأكيد + الرجاء الانتظارو يتم إعادة حساب سجلات القرض + تم الانشاء في + أهلا + أهلا %1$s + تدفق المال: %1$s%2$s %3$s + ابحث المعاملات + مفتوح المصدر Ivy Wallet + هدف التوفير + الوصول السريع + الاعدادات + وضع النهار + الوضغ الليلي + الوضع التلقائي + المدفوعات\n المخططة + شارك التطبيق + التقارير + القروض + تحديد العملة + لا يوجد معاملات + لا يوجد لديك معاملات في %1$s \n"+" يمكنك اضافة معاملة بالضغط على + اضافة قرض + لا يوجد قروض + لا يوجد لديك اي قروض.\n اضغط على "اضافة قرض +" للإضافة + ملاحظة: حذف هذا القرض سيزيله نهائيا و يحذف جميع القروض التابعة له + الرجاء الانتظار, يتم اعادة حساب كل سجلات القروض + مدفوع + %1$s %2$s متبقي + فائدة القرض + %1$s %2$s مدفوعة + إضافة سجل + الفائدة + لا يوجد سجلات + لا يوجد لديك اي سجلات لهذا القرض.\n اضغط على "اضافة سجل +" للإضافة + إضافة دخل + إضافة إنفاق + غير محدد + %1$s\%% + تحويلات الحساب + %1$sلا يوجد لديك اي معاملات في.\n يمكنك إضافة معاملة بالسحب لاسفل و الضغط على زر "أضف دخل" أو "أضف إنفاق" في الاعلى. + ملاحظة: حذف هذا الحساب سيزيله نهائيا و يزيل كل المعاملات المتعلقه به. + ملاحظة: حذف هذه الفئة سيزيلها نهائيا + تعديل + المعاملات + الرئيسية + إضافة مدفوعات مخططة + إضافة دخل + إضافة إنفاق + تحويل لحساب + تخطي + إضافة جديد + من %1$s + إلى %1$s + المجال + الخصوصية و\n تجميع البيانات + اسحب للموافقة على الشروط و الأحكام + تمت الموافقة على الشروط و الأحكام + اسحب للموافقة على سياسة الخصوصية + تمت الموافقة على سياسة الخصوصية + الشروط و الأحكام + سياسة الخصوصية + تتبع دخلك, إنفاقك و ميزانيتك مع Ivy.\n\n تصميم أنيق, دفعات مخططة و متكررة, أدِر حسابات متعددة, نظم المعاملات في فئات, إحصائيات معبرة, صدّر المعاملات الى ملف csv و أكثر. + أدخل اسمك\n لتخصيص محفظتك + ما اسمك؟ + إدخال + إضافة حسابات + مقترح + التالي + إضافة فئات + مقترحات + تحديد + مدير أموالك الشخصي + مفتوح_المصدر# + حدث خطأ, حاول مجددًا:\n %1$s + يتم تسجيل الدخول… + تم بنجاح! + Google تسجيل الدخول بحساب + حساب غير متصل على الانترنت + Ivy Cloud مزامنة بياناتك على + سلامة و حماية البيانات غير مضمونة! + أو الدخول بحساب غير متصل على الانترنت + سوف يتم حفظ بياناتك محليا على جهازك و لن يتم مزامنتها مع السحابة. انت تخاطر بخسارة ملفاتك إذا ألغيت تثبيت التطبيق أو غيرت جهازك. يمكنك دائما تفعيل المزامنة لاحقا في حال تغيير رأيك. + الشروط و الأحكام + بتسجيل الدخول انت توافق على %1$s و %2$s. + CSV إستيراد ملف + Ivy من تطبيق اخر او من + استيراد ملف نسخ احتياطي من تطبيق اخر يمكن ان يستغرق حتى 5 دقائق. يمكنك دائمًا استيراد البيانات لاحقًا اذا اردت. + استيراد ملف نسخ احتياطي + ابدأ من الصفر + حذف هذه الدفعه المخططة سيزيل كل المدفوعات الغير مدفوغة او المدفوعات المتأخرة القادمة المتعلقة بها. + تحديد نوع العملية + مخطط البدء في + تكرر كل %1$d %2$s + محذوف + مخطط في + null + "تبدأ %1$s " + إضافة دفعة + المدفوعات لمرة واحدة + الدفعات متكررة + لا توجد مدفوعات مخططة + لا يوجد لديك دفعات مخططة.\n اضغط على \'⚡\' في الاسفل لإضافة واحدة. + الدفعات المخططة + اليوم + أمس + غدًا + محدد الدفع في %1$s + قادم + متأخر + الانفاق + الدخل + تعديل الحساب + حساب جديد + اسم الحساب + تضمين الحساب + ادخل رصيد الحساب + اختر العملة + الحاسبة + العملية الحسابية (+-/*=) + تعديل الفئة + انشاء فئى + اسم الفئة + اتر الفئة + ادخل اي تفاصيل هنا + إزالة التصفيات + تصفية + تطبيق التصفيات + النوع + الدخل + الفترة زمنية + حدد النطاق الزمني + حسابات (%1$d) + فئات (%1$d) + ازالة الكل + تحديد الكل + القيمة (اختياري) + الكلمات المفتاحية (اختياري) + تحتوي + اضف كلمة مفتاحية + استبعد + لا يوجد معاملات لتصفياتك + بدون تصفيات + لإنشاء تقرير يجب عليك تحديد تصفيات صالحة اولا. + تحديد التصفيات + تصدير + لا يوجد لديك عمليات تطابق "%1$s" + التصدير و الاستيراد + نسخ إحتياطي للبيانات + استيراد البيانات + إعدادات التطبيق + أقفل التطبيق + إظهار التنبيهات + إخفاء الرصيد + إضغط على الرصيد المخفي لإظهاره لمدة 5ث + أخرى + Google Play قيمنا على + Ivy Wallet شارك + المنتج + المنطقة الخطرة + احذف كل بيانات المستخدم + هل تريد حذف كل البيانات؟ + تحذير! هذا الفعل سوف يحذف كل البيانات ل%1$s نهائيا و لن يمكن اسنعادتها. + حسابك + تأكيد الحذف النهائي ل\'%1$s\' + كل بياناتك + تحذير اخير! بعد الضغط على "حذف" بياناتك سوف تحذف للأبد + يتم تصدير البيانات + الرجاء الانتظار, يتم تصدير البيانات + تاريخ بداية الشهر + على تيليجرام Ivy + مركز المساعدة + الخطة + طلب ميزة + اتصل بالدعم + المساهمون في التطبيق + الحساب + تسجيل الخروج + تسجيل الدخول + يتم المزامنة… + تمت مزامنة البيانات للسحابة + أضغط للمزامنة + فشلت عملية المزامنة. أضغط للمزامنة + مجهول + CSV التصدير لملف + المتبقي للإنفاق + تجاوزت عن الميزانية ب + تجاوزت حاجز الإنفاق ب + تحديد نوع المعاملة + تحويل + تم تحديده + (USD, EUR, GBP, BTC, الخ) ابحث + محدد مسبقًا + عملة رقمية + سعر الصرف + اختر اللون + إعادة ترتيب + الكلمة المفتاحية + تعديل الميزانية + إنشاء ميزانية + اسم الميزانية + قيمة الميزانية + هل انت متأكد من حذف الميزانية "%1$s"؟ + تعديل هدف التوفير + اختر أيقونة + اختر شهر + أو نطاق مخصص + أضف تاريخ + أو في آخر + أو كل الوقت + إلغاء تحديد كل الأوقات + تحديد كل الأوقات + أختر تاريخ بداية الشهر + تدعم العملات الرقمية + حذف + حفظ + أضافة + إنشاء + تعديل القرض + قرض جديد + اسم القرض + الحساب المرتبط + إنشاء معاملة أساسية + أدخل قيمة القرض + "ملاحظة: انت تحاول ان تغير حساب مرتبط بقرض من حساب بعملة مختلفة, \n سجلات القرض سوف يتم اعادة حسابها وفقا لأسعار الصرف اليوم" + نوع القرض + اقتراض مال + إقراض مال + تعديل السجل + سجل جديد + ملاحظة + وضع علامة: فائدة + أعد حساب القيمة وفقا لأسعار الصرف اليوم + أدخل قيمة السجل + هل انت متأكد من حذف السجل "%1$s"؟ + "ملاحظة: انت تحاول ان تغير حساب مرتبط بقرض من حساب بعملة مختلفة, \n سجلات القرض سوف يتم اعادة حسابها وفقا لأسعار الصرف اليوم" + تعديل الاسم + خطط ل + مرة واحدة + مرات متعددة + تبدأ في + تتكرر كل + إرسال + ماذا تحتاج؟ + (Markdown تدعم صيغة) اشرحها في جملة واحدة + آخر 12 شهر + آخر 6 أشهر + آخر 4 أسابيع + آخر 7 أيام + اليوم, %1$s + أمس, %1$s + غدًا, %1$s + منتهي الصلاحية + تم تأكيدالهوية. + فشل تأكيدالهوية. + هل قمت بأي معاملات اليوم؟ 🏁 + هل قمت بمتابعة إنفاقك اليوم؟ 💸 + هل قمت بتسجيل معاملاتك اليوم؟ 🏁 + نقدي + البنك + Revolut + أطعمة ومشروبات + الفواتير والرسوم + المواصلات + البقالة + الترفيه + التسوق + هدايا + الصحة + استثمار + السيارة + العمل + مطعم + العائلة + الحياة الاجتماعية + طلب الطعام + السفر + اللياقة البدنية + تطوير الذات + ملابس + الجمال + التعليم + الحيوان الأليف + رياضات + اضبط رصيدك المبدئي + انتقل الى الحسابات + اضغط على حساب -> اضغط على الرصيد -> ادخل رصيد الحساب. هذا كل شيء]]> + قم بإنشاء أول دفعة مخططة + قم بأتمتة المعاملات المتكررة مثل الاشتراكات, الايجار, الراتب, الخ.. كن في معرفة بأموالك من خلال معرفة المبلغ الذي يتعين عليك دفعه/الحصول عليه مقدمًا + هل كنت تعلم؟ + Ivy Wallet \nلديها تطبيقات مصغرة (ويدجت) رائعة تتيح لك إضافة دخل/إنفاق/تحويل بضغطة واحدة فقط من الشاشة الرئيسية \n\n ملاحظ: اذا لم يعمل زر \"إضافة تطبيق مصغر\" يرجى اضافته يدويا عن طريق قائمة التطبيقات المصغرة في شاشتك الرئيسية + إضافة تطبيق مصغر + تحديد ميزانية + Ivy Wallet \nلا تساعدك على تتبع نفقاتك فحسب ، بل تساعدك أيضًا في إنشاء مستقبلك المالي بشكل استباقي من خلال تحديد الميزانيات والالتزام بها. + يمكنك أن ترى مخطط النفقات الخاصة بك حسب الفئات! جربه ، اضغط على زر الإنفاق الرمادي/الأسود الموجود أسفل رصيدك مباشرة. + إحصائيات الإنفاق + Ivy Wallet قيم + أعطنا تقييمك! ساعد Ivy Wallet في أن تصبح أفضل وتنمو من خلال كتابة مراجعة لنا. المجاملات والأفكار والنقاد مرحب بهم! نفعل أفضل ما بوسعنا\n فريق التطبيق + ساعدنا على النمو حتى نتمكن من استثمار المزيد في التطوير وجعل التطبيق أفضل لك. عبر خلال مشاركة التطبيق ، ستجعل اثنين من المطورين سعداء وتساعد أيضًا صديقًا على إدارة امواله. + شارك مع الاصدقاء + يمكنك إنشاء تقارير للحصول على رؤى عميقة حول دخلك وإنفاقك. قم بتصفية معاملاتك حسب النوع والفترة الزمنية والفئة والحسابات والمبلغ والكلمات المفتاحية والمزيد للحصول على عرض أفضل لأموالك. + إنشاء سجل + هل تريد تحسين Ivy Wallet؟ اكتب لنا مراجعة. هذه هي الطريقة الوحيدة لتطوير ما تريده وتحتاجه. كما أنها تساعدنا في الحصول على مرتبة أعلى في PlayStore حتى نتمكن من إنفاق الأموال على المنتج بدلاً من التسويق. \n\n نحن نبذل قصارى جهدنا. \ n Ivy Team + نحتاج مساعدتك! + نحن مجرد مصمم ومطور نعمل على التطبيق بعد وظائفنا. في الوقت الحالي ، نستثمر الكثير من الوقت والمال لتوليد الخسائر والإرهاق فقط. إذا كنت تريد منا الاستمرار في تطوير Ivy Wallet ، فيرجى مشاركتها مع الأصدقاء والعائلة. \n\n ملاحظة. تساعد مراجعات Google PlayStore أيضًا كثيرًا! + مفتوح المصدر Ivy Wallet + كود التطبيق مفتوح ويمكن للجميع رؤيته. نعتقد أن الشفافية والأخلاق ضرورية لكل منتج برمجي. إذا كنت تحب عملنا وترغب في تحسين التطبيق ، يمكنك المساهمة في حساب Github العام الخاص بنا. + ساهم + أضبط الرصيد + تأكيد الهوية مطلوب + أثبت أن لديك حق الوصول إلى هذا الجهاز لإلغاء قفل التطبيق. + الميزانية الإجمالية + ميزانية بفئة + ميزانية بفئات (%1$s) + مٌقتَرَض + مُعَار + يناير + فبراير + مارس + ابريل + مايو + يونيو + يوليو + اغسطس + سبتمبر + اكتوبر + نوفمبر + ديسمبر + أيام + يوم + أسابيع + أسبوع + شهور + شهر + سنوات + سنة + التحويلات كدخل و إنفاق + معاملة تحويلات الحساب كإنفاق/دخل في صفحة الحسابات + البيت + يتم إنشاء التقرير … + ترتيب حسب + تخطي الكل + تأكيد تخطي الكل + هل أنت متأكد من أنك تريد تخطي جميع المعاملات المخطط لها المتأخرة؟ + التبديل إلى وضع بدون انترنت + تحذير! سوف يؤدي هذا الى حذف كل بياناتك المخزنة في السحابة ل%1$s نهائيا, لن يتم حذف البيانات المخزنة على جهازك. + حذف كل اليانات على السحابة؟ + تجريبي + الإعدادات التجريبية + رصيد المحفظة + وضع علامة كفئة فرعية + القسم الرئيسي + * تم وضع علامة على أنها فئة الأصل + فك جميع الفئات الفرعية + حدد جميع \"الأعمدة الاختيارية\" في خيارات التصدير + إعادة تسمية فئة النقل + بالنسبة للمستخدمين غير الإنجليز ، أعد تسمية فئة نظام النقل إلى \"نقل\" في ملف التصدير + تحفظات + الفئات الفرعية غير مدعومة + سيتم تجاهل أعمدة الأحداث والأشخاص والمكان + diff --git a/resources/src/main/res/values-bg/strings.xml b/resources/src/main/res/values-bg/strings.xml new file mode 100644 index 0000000..ba04649 --- /dev/null +++ b/resources/src/main/res/values-bg/strings.xml @@ -0,0 +1,440 @@ + + + Сметки + Общо: %1$s %2$s + ПРИХОДИ ТОЗИ МЕСЕЦ + РАЗХОДИ ТОЗИ МЕСЕЦ + (изключен) + ПРИХОДИ + РАЗХОДИ + ЗАКЛЮЧЕН + Отключете, за влизане + Отключи + СЕГАШЕН БАЛАНС + БАЛАНС СЛЕД ПЛАНИРАНИ + Свързване + Синхронизация + Синхронизиране… + Банкови сметки активирани + Премахни потребител + Добави бюджет + Няма бюджети + Нямате бюджети.\nНатиснете "+ Добави бюджет", за да създатете. + Бюджети + %1$s %2$s за категории + %1$s %2$s бюджет + Бюджет: %1$s / %2$s + Бюджет: %1$s%2$s + Добави категория + Разходи + Брой разходи + Приход + Брой приходи + Баланс + БАЛАНС %1$s + Графики + Период: + Категории + Експортирай CSV + Експортирай стандартен CSV + Моля използвайте стандартните настройки. + Как да импортираме + отвори + Стъпки + Как да + Видео + Статия + Качи CSV файл + Експортиране + Качи CSV/ZIP файл + Експортирай във файл + Character set: UTF-8\nDecimal separator: Decimal point \'.\'\nDelimiter character: Comma \',\' + Експортиране в Excel + Конвертиране от XLS в CSV + !Важно: If the exported file doesn\'t have ".xls" extension, add it by renaming the file manually. + Безплатен онлайн CSV конвертор + Проверете "Promotions" и "Spam" папките в имейла си. + Download the \"transactions_export…\" file attached to the email. + If you have more than one currency you\'ll have to download each \"transactions_export…\" file and import it in Ivy. + Импортиране от + Моля изчакайте + Импортиране на CSV файл + Успех + Грешка + Импортирано + %1$d транзакции + %1$d сметки + %1$d категории + Грешка + %1$d реда от CSV файла не бяха разпознати + Завърши + Добави описание + Описание + Планирано за + Добави пари в + Плати с + От + Сметка + До + Добави сметка + Заглавие на приход + Заглавие на разход + Заглавие на трансфер + Разход + Добави планирана дата + Плати + Вземи + Потвърди изтриване + След като изтриете транзакцията тя ще изчезне и балансът ви ще се обнови. + Потвърди смяна на сметка + Важно: You are trying to change the account associated with the loan with an account of different currency, \nAll the loan records will be re-calculated based on today\'s exchanges rates + Потвърди + Моля изчакайте, изчисляване + Създадено на + Хей + Хей %1$s + Печалба: %1$s%2$s %3$s + Търси транзацкии + Ivy Wallet е open-source + Спестовна цел + Бърз достъп + Найстройки + Бяла тема + Черна тема + Автоматична + Планирани\nПлащания + Сподели Ivy + Репорти + Заеми + Задай валута + Няма транзакции + Нямате транзакции за %1$s.\nМоже да добавите такива като натиснете \"+\" бутона. + Добави заем + Няма заеми + Нямате заемо.\nЗа да добавите натиснете \"+ Добави заемо\" бутона. + Важно: Ако изтриете заема всички записи свързани с него ще бъдат изтрити. + Моля изчакайте, пресмятане + Платено + %1$s %2$s остава + Лихва + %1$s %2$s платено + Добави запис + Лихва + Няма записи + Нямате записи по този заемо. Натиснете "Добави запис", за да създадете. + Добави приход + Добави разход + Неопределен + %1$s\%% + Трансфер + Нямате транзакции за %1$s.\nYou can add one by scrolling down and tapping "Add income" or "Add expense" button at the top. + Важно: Ако изтриете сметката, всички транзацкии в нея ще бъдат изтрити. + Важно: Перманентно изтриване на категория. + Редакция + транзацкии + Начален + Добави планирано плащане + Добави приход + Добави разход + Трансфер + Пропусни + Добави + От %1$s + До %1$s + Период + Privacy and\ndata collection + Swipe to agree with our Terms and conditions + Agreed with our Terms and conditions + Swipe to agree with our Privacy policy + Agreed with our Privacy policy + Общи условия + Privacy policy + Track your income, expenses and budget with Ivy.\n\nIntuitive UI, recurring and planned payments, manage multiple accounts, organize transactions in categories, meaningful statistics, export to CSV and so much more. + Enter your name\nto personalize your\nwallet + What\'s your name? + Задай + Добави сметка + Предложения + Следващ + Категории + Предложения + Задай + Твоят финансов помощник + #opensource + Грешка. Опитай пак: %1$s + Вписване… + Успех! + Влез с Google + Офлайн акаунт + СИНХРОНИЗАЦИЯ С IVY CLOUD + Сигурността на данните не е гарантирана! + ИЛИ ВЛЕЗ С ОФЛАЙН АКАУНТ + Данните ти ще бъдат съхванявани само локално, на устройството. Има риск да ги загубите. + + By signing in, you agree with our %1$s and %2$s. + Импортирай CSV файл + от Ivy Wallet или друго приложение + Импортирай файл за възстановяване. + Импортирай + Стартирай на чисто + Ако изтриете планираното плащане всички неплатени транзакции ще бъдат изтрити заедно с него. + Тип плащане + Планиран старт + ПОВТАРЯ СЕ ВСЕКИ %1$d %2$s + изтрито + "ПЛАНИРАНО ЗА " + null + "СТАРТИРА %1$s " + Добави плащане + Еднократно плащане + Повтарящо се плащане + Няма планирани плащания + Нямате планирани плащания.\nНатиснете \'⚡\' отдолу, за да добавите такова. + Планирани плащания + Днес + Вчера + Утре + Дължи на %1$s + Предстоящи + Закъснели + разходи + приходи + Редакция + Нова сметка + Сметка + Включен в баланс + Баланс на сметка + Избери валута + Калкулатор + Сметка (+-/*=) + Редакция + Създай категория + Име на категория + Избери категория + Добави детайли + Изчисти филтър + Филтър + Задай филтър + По тип + Приходи + Времеви период + Избери период + Сметки (%1$d) + Категории (%1$d) + Изчисти всички + Избери всички + Размер (по избор) + Ключови думи (по избор) + ВКЛЮЧВА + Добави включова дума + НЕ ВКЛЮЧВА + Не е зададен филтър. + Няма филтър + За да генерирате отчет първо задайте валиден филтър. + Задай Филтър + Експортиране + Няма транзакции за тази заявка: "%1$s". + + Backup Data + Импортиране + Найстроки + Заключване + Известия + Скрий баланс + Натиснете на скрития баланс, за да го видите. + Други + Оценете ни в Google Play + Сподели Ivy Wallet + Продукт + Опасна зона + Изтрий всички данни + Изтриване? + ОПАСНО! Това действие ще изтрие %1$s ЗАВИНАГИ и необратимо. + сметка + Потвърди изтриване завинаги: \'%1$s\' + всичките ви данни + ПОСЛЕДНО ПРЕДУПРЕЖДЕНИЕ! След като натисните "Изтрий" данните ви ще бъдат изтрити завинаги. + Експортиране + Моля изчакайте + Начална дата на месец + Ivy Telegram + Помощен център + План + Поискай функционалност + Техническа поддръжка + Разработчици + АКАУНТ + Изход + Вход + Синхронизация… + Успешна синхронизация + Синхронизиране + Неуспешно. Натиснете за синх. + Анонимен + Ескпортиране в CSV + Оставащи + Надвишен с + Надвишен с + Тип транзацкия + Трансфер + Избран + Търсене (USD, EUR, GBP, BTC, и тн.) + Пре-избран + Crypto + Курс + Избери цвят + Пренареди + Ключова дума + Редакция + Създай бюджет + Име на бюджет + РАЗМЕР БЮДЖЕТ + Наистина ли искате да изтриете този бюдбет: "%1$s"? + Редактирай цел + Избери иконка + Избери месец + или период + Добави дата + или в последните + или завинаги + От-селектирай Завинаги + Избери Завинаги + Избери начална дата на месец + поддържа crypto + Изтрий + Запази + Добави + Създай + Редакция + Нов заем + Име на заем + Свързана сметка + Създай транзацкия + РАЗМЕР НА ЗАЕМА + "Важно: You are trying to change the account associated with the loan with an account of different currency, \nAll the loan records will be re-calculated based on today's exchanges rates " + Тип заем + Вземане на пари + Даване на пари + Редакция + Нов запис + Бележка + Маркирай като лихва + Преизчисляване на стойност спрямо актуален курс + СТОЙНОСТ НА ЗАПИСА + Сигурни ли сте че искате да изтриете този запис: "%1$s"? + "Важно: You are trying to change the account associated with the loan record with an account of different currency\nThe amount will be re-calculated based on today's exchanges rates " + Смени име + Планирано за + Еднократно + Многократно + Започва на + Повтаря се всеки + Изпрати + От какво имате нужда? + Обяснете в едно изречение. (supports markdown) + Последните 12 месеца + Последните 6 месеца + Последните 4 седмици + Последните 7 дни + Днес, %1$s + Вчера, %1$s + Утре, %1$s + Изтекъл + Успешен вход! + Неуспешен вход. + Въведохте ли разходите си днес? 🏁 + Въведохте ли разходите си днес? 💸 + Въведохте ли разходите си днес? 🏁 + Кеш + Банка + Revolut + + + Транспорт + За дома + Забавление + Пазаруване + Подаръци + Здраве + Инвестиции + Кола + Работа + Ресторант + Семейство + Социален Живот + Поръчване на храна + Пътувания + Фитнес + Себе-развитие + Дрехи + Красота + Образование + Домашен любимец + Спорт + Задайте начален баланс + До сметка + Tap an account -> Tap its balance -> Enter current balance. That\'s it!]]> + Create your first planned payment + Automate the tracking of recurring transactions like your subscriptions, rent, salary, etc. Stay ahead of your finances by knowing how much you have to pay/get in advance. + Знаехте ли че? + Ivy Wallet has a cool widget that lets you add INCOME/EXPENSES/TRANSFER transactions with 1-click from your home\n\nNote: If the "Add widget" button doesn\'t work, please add it manually from your launcher\'s widgets menu. + Add widget + Задай бюджет + Ivy Wallet not only helps you to passively track your expenses but also proactively create your financial future by setting budgets and sticking to them. + You can see your expenses structure by categories! Try it, tap the gray/black Expenses button just below your balance. + Expenses PieChart + Review Ivy Wallet + Give us your feedback! Help Ivy Wallet become better and grow by writing us a review. Compliments, ideas, and critics are all welcome! We do our best.\n\nCheers,\nIvy Team + Help us grow so we can invest more in development and make the app better for you. By sharing Ivy Wallet you\'ll make two developers happy and also help a friend to take control of their finances. + Share with friends + You can generate reports to get deep insights about your income and spending. Filter your transactions by type, time period, category, accounts, amount, keywords and more to gain better view on your finances. + Make a report + Want to make Ivy Wallet better? Write us a review. That\'s the only way for us to develop what you want and need. Also it help us rank higher in the PlayStore so we can spend money on the product rather than marketing.\n\nWe do our best.\nIvy Team + We need your help! + We\'re just a designer and a developer working on the app after our 9–5 jobs. Currently, we invest a lot of time and money to generate only losses and exhaustion. If you want us to keep developing Ivy Wallet please share it with friends and family.\n\nP.S. Google PlayStore reviews also helps a lot! + Ivy Wallet is open-source! + Ivy Wallet\'s code is open and everyone can see it. We believe that transparency and ethics are must for every software product. If you like our work and want to make the app better you can contribute in our public Github repository. + Contribute + Задай баланс + Authentication required + Prove that you have access to this device to unlock the app. + Глобален бюджет + За катеогрия + За %1$s катеория + ВЗЕТИ + ДАДЕНИ + Януари + Февруари + Март + Април + Май + Юни + Юли + Август + Септември + Октомври + Ноември + Декември + дни + ден + седмици + седмица + месеци + месец + години + година + + Treats account transfers as income or expense in Accounts Screen + Дом + Generating report… + sortirane po + прескочете всички + Потвърдете пропускането на всички + Сигурни ли сте, че искате да пропуснете всички просрочени планирани операции? + Switch to offline mode + WARNING! This action will delete all your cloud-stored data for %1$s PERMANENTLY, the offline data stored in your local app will remain. + Delete all cloud-stored data? + Experimental + Experimental Settings + Wallet balance + \ No newline at end of file diff --git a/resources/src/main/res/values-es/strings.xml b/resources/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..bf15209 --- /dev/null +++ b/resources/src/main/res/values-es/strings.xml @@ -0,0 +1,440 @@ + + + Cuentas + Total: %1$s %2$s + INGRESOS ESTE MES + GASTOS ESTE MES + (excluido) + INGRESOS + GASTOS + APP BLOQUEADA + Autentifícate para ingresar a la app + Desbloquear + SALDO ACTUAL + SALDO DESPUÉS DE PAGOS PLANIFICADOS + Conectar + Sincronizar transacciones + Sincronizando transacciones… + Sincronización bancaria habilitada: + Eliminar cliente + Agregar presupuesto + Sin presupuestos + No tienes ningún presupuesto establecido..\nToque "+ Agregar presupuesto" para agregar uno. + Presupuestos + %1$s %2$s por categorías + %1$s %2$s presupuesto de aplicación + información de presupuesto: %1$s / %2$s + información de presupuesto: %1$s%2$s + Agregar categoría + Gastos + Cuenta de gastos + Ingresos + Cuenta de ingresos + Gráfico de balance + BALANCE %1$s + Gráficos + Periodo: + Categorías + Exportar archivo CSV + Exportar archivo CSV con opciones estándar + Utilice las opciones estándar y asegúrese de incluir encabezados. + Como importar + abierto + Pasos + Cómo + Video + Articulo + Subir archivo CSV + Exportar Datos + Subir archivo CSV/ZIP + Exportar a un archivo + Conjunto de caracteres: UTF-8\nSeparador decimal: Punto decimal \'.\'\nCarácter delimitador: Coma \',\' + Exportar archivo de Excel + Convertir XLS a CSV + !NOTA: Si el archivo exportado no tiene la extensión ".xls", agréguelo cambiando el nombre del archivo manualmente. + Convertidor CSV en línea GRATIS + Revisa las carpetas de "Promociones" y "Spam" de tus correos electrónicos + Descargue el archivo \"transactions_export…\" adjunto al correo electrónico. + Si tiene más de una moneda, deberá descargar cada archivo \"transactions_export…\" e importarlo en Ivy. + Importar de + Espere por favor + Importando el archivo CSV + Listo + Fracaso + Importado + %1$d transacciones + %1$d cuentas + %1$d categorías + Fallido + %1$d filas del archivo CSV no reconocidas + Finalizar + Agregar descripción + Descripción + Planificado para + Añadir dinero a + Pagar con + Desde + Cuenta + Hasta + Añadir cuenta + Título de ingreso + Título del gasto + Título de transferencia + Gastos + Añadir fecha prevista de pago + Pagar + Obtener + Confirmar la eliminación + Eliminar esta transacción la eliminará del historial de transacciones y actualizará el saldo en consecuencia. + Confirmar cambio de cuenta + Nota: Está intentando cambiar la cuenta asociada con el préstamo con una cuenta de moneda diferente, \nTodos los registros del préstamo se volverán a calcular en función de las tasas de cambio de hoy + Confirmar + Espere, recalculando todos los registros de préstamo + Creado el + Hola + Hola %1$s + Flujo de fondos: %1$s%2$s %3$s + Buscar transacciones + Ivy Wallet es de código abierto + meta de ahorro + Acceso rápido + Ajustes + Modo claro + Modo oscuro + Modo automático + Pagos\nPlanificados + Comparte Ivy + Reportes + Préstamos + Establecer moneda + Sin transacciones + No tienes ninguna transacción para %1$s.\nPuedes agregar una tocando el botón \"+\". + Agregar préstamo + Sin préstamos + No tienes ningún préstamo.\nToca \"+ Agregar préstamo\" para agregar uno. + Nota: Al eliminar este préstamo, se eliminará de forma permanente y se eliminarán todos los registros de préstamos asociados. + Espere, recalculando todos los registros de préstamo + Pagado + %1$s %2$s restantes + Intereses del préstamo + %1$s %2$s pagados + Agregar registro + Interés + Sin registros + No tiene ningún registro para este préstamo. Toque "Agregar registro" para crear uno. + Añadir ingresos + Agregar gasto + Sin especificar + %1$s\%% + Transferencias de cuenta + No tiene ninguna transacción para %1$s.\nPuede agregar una desplazándose hacia abajo y tocando el botón "Agregar ingresos" o "Agregar gastos" en la parte superior. + Nota: Eliminar esta cuenta la eliminará de forma permanente y eliminará todas las transacciones asociadas con ella. + Nota: Eliminar esta categoría la eliminará de forma permanente. + Editar + transacciones + Inicio + Agregar pago planificado + AGREGAR INGRESOS + AGREGAR GASTO + TRANSFERENCIAS DE CUENTA + Saltar + Agregar nuevo + Desde %1$s + Para %1$s + Rango + Privacidad y\nrecopilación de datos + Desliza para aceptar nuestros Términos y condiciones + De acuerdo con nuestros Términos y condiciones + Desliza para aceptar nuestra política de privacidad + De acuerdo con nuestra política de privacidad + Términos y condiciones + Política de privacidad + Realice un seguimiento de sus ingresos, gastos y presupuesto con Ivy.\n\nInterfaz de usuario intuitiva, pagos recurrentes y planificados, administre múltiples cuentas, organice transacciones en categorías, estadísticas significativas, exporte a CSV y mucho más. + Introduce tu nombre\npara personalizar tu\nbilletera + ¿Cuál es tu nombre? + Ingresar + Agregar cuentas + Sugerencia + Siguiente + Añadir categorías + Sugerencias + Establecer + Tu gestor personal de dinero + #opensource + Error. Intenta de nuevo: %1$s + Iniciando sesión… + ¡Éxito! + Iniciar sesión con Google + cuenta sin conexión + SINCRONIZA TUS DATOS EN LA NUBE DE IVY + ¡La integridad y la protección de los datos no están garantizadas! + O ENTRAR CON UNA CUENTA OFFLINE + Sus datos se guardarán localmente (solo en su teléfono) y no se sincronizarán con la nube. Corre el riesgo de perderlo si desinstala la aplicación o cambia su dispositivo. Siempre puedes activar la sincronización más tarde si decides. + + Al iniciar sesión, acepta nuestros %1$s y %2$s. + Importar archivo CSV + de Ivy u otra aplicación + La importación de un archivo de copia de seguridad desde otro puede tardar hasta 5 minutos. Siempre puede importar sus datos más tarde si lo desea. + Importar archivo de copia de seguridad + Empezar de nuevo + Al eliminar este pago planificado, se eliminarán todas las transacciones próximas o vencidas no pagadas asociadas con él. + Establecer tipo de pago + Comienzos previstos en + REPITE CADA %1$d %2$s + eliminado + "PLANIFICADO PARA " + nulo + "EMPIEZA %1$s " + Agregar pago + Pagos únicos + Pagos recurrentes + Sin pagos planificados + No tiene ningún pago planificado.\nPresione la parte inferior \'⚡\' en la parte inferior para agregar uno. + Pagos planificados + Hoy + Ayer + Mañana + Vencimiento el %1$s + Próximos + Atrasado + gastos + ingreso + Editar cuenta + Nueva cuenta + Nombre de la cuenta + Incluir cuenta + Ingrese el saldo de la cuenta + Elegir Moneda + Calculadora + Cálculo (+-/*=) + Editar categoría + Crear categoría + Nombre de la categoría + Elegir la categoría + Ingrese cualquier detalle aquí + Borrar filtros + Filtrar + Aplicar filtros + Por tipo + Ingresos + Periodo de tiempo + Seleccionar rango de tiempo + cuentas (%1$d) + Categorías (%1$d) + Limpiar todo + Seleccionar todo + Cantidad (opcional) + Palabras clave (opcional) + INCLUYE + Agregar una palabra clave + EXCLUYE + No tienes transacciones para tu filtro. + Sin filtro + Para generar un informe, primero debe establecer un filtro válido. + Establecer filtro + Exportar + No tienes transacciones para la consulta "%1$s". + + Respaldo de datos + Importar datos + Ajustes de Aplicación + Bloquear la aplicación + Mostrar notificaciones + Ocultar saldo + Haz click en el saldo oculto para mostrar el saldo por 5 segundos + Otro + Califícanos en Google Play + Compartir Ivy Wallet + Producto + Zona peligrosa + Eliminar todos los datos del usuario + ¿Eliminar todos los datos de usuario? + ¡ADVERTENCIA! Esta acción eliminará todos los datos de %1$s PERMANENTEMENTE y no podrá recuperarlos. + tu cuenta + Confirmar la eliminación permanente de \'%1$s\' + todos tus datos + ¡ÚLTIMA ADVERTENCIA! Después de hacer clic en "Eliminar", sus datos desaparecerán para siempre. + Exportar datos + Por favor espere, exportando datos + Fecha de inicio del mes + Ivy en Telegram + Centro de ayuda + Hoja de ruta + Solicitar una característica + Contactar con soporte + Colaboradores del proyecto + CUENTA + Cerrar sesión + Iniciar sesión + Sincronizando… + Datos sincronizados con la nube + Toca para sincronizar + Error de sincronización. Toca para sincronizar + Anónimo + Exportar a CSV + Queda para gastar + Presupuesto excedido por + Reserva excedida por + Establecer tipo de transacción + Transferir + Seleccionado + Buscar (USD, EUR, GBP, BTC, etc.) + Preseleccionado + Cripto + Tipo de cambio + Elegir color + Reordenar + Palabra clave + Editar presupuesto + Crear presupuesto + Nombre del presupuesto + CANTIDAD DE PRESUPUESTO + ¿Está seguro de que desea eliminar el presupuesto "%1$s"? + Editar objetivo de ahorro + Elegir icono + Elegir mes + o rango personalizado + Agregar fecha + o en el último + o todo el tiempo + Deseleccionar todo el tiempo + Seleccionar todo el tiempo + Elija la fecha de inicio del mes + soporta cripto + Borrar + Guardar + Agregar + Crear + Editar préstamo + Nuevo préstamo + Nombre del préstamo + Cuenta asociada + Crear una transacción principal + INGRESE EL MONTO DEL PRÉSTAMO + "Nota: está intentando cambiar la cuenta asociada con el préstamo con una cuenta de moneda diferente, \nTodos los registros del préstamo se volverán a calcular en función de las tasas de cambio de hoy" + Tipo de préstamo + Obtener préstamo + Prestar dinero + Editar registro + Nuevo récord + Nota + Marcar como interés + Recalcular la cantidad con las tasas de cambio de divisas de hoy + INGRESE LA CANTIDAD DE REGISTRO + ¿Está seguro de que desea eliminar el registro "%1$s"? + "Nota: está intentando cambiar la cuenta asociada con el registro de préstamo con una cuenta de moneda diferente\nEl monto se volverá a calcular en función de las tasas de cambio de hoy" + Editar nombre + Plan para + Una vez + Múltiples veces + Comienza el + Se repite cada + Entregar + ¿Qué necesitas? + Explícalo en una frase. (admite Markdown) + últimos 12 meses + últimos 6 meses + últimas 4 semanas + Los últimos 7 días + Hoy, %1$s + Ayer, %1$s + Mañana, %1$s + Caducado + ¡La autenticación se realizó correctamente! + La autenticación falló. + ¿Hiciste alguna transacción hoy? 🏁 + ¿Hiciste un seguimiento de tus gastos hoy? 💸 + ¿Has registrado tus transacciones hoy? 🏁 + Efectivo + Banco + Revolut + + + Transporte + Comestibles + Entretenimiento + Compras + Regalos + Salud + Inversiones + Coche + Trabajo + Restaurante + Familia + Vida social + Ordenes de comida + Viaje + Condición física + Autodesarrollo + Ropa + Belleza + Educación + Mascota + Deportes + Ajusta tu saldo inicial + a las cuentas + Toque una cuenta -> Toque su saldo -> Ingrese el saldo actual. ¡Eso es todo!]]> + Crea tu primer pago planificado + Automatice el seguimiento de transacciones recurrentes como sus suscripciones, alquiler, salario, etc. Manténgase al tanto de sus finanzas al saber cuánto tiene que pagar/obtener por adelantado. + ¿Sabías? + Ivy Wallet tiene un widget genial que le permite agregar transacciones de INGRESOS/GASTOS/TRANSFERENCIAS con 1 clic desde su hogar\n\nNota: si el botón "Agregar widget" no funciona, agréguelo manualmente desde su lanzador\' menú de widgets. + Añadir widget + Establecer un presupuesto + Ivy Wallet no solo lo ayuda a realizar un seguimiento pasivo de sus gastos, sino que también crea proactivamente su futuro financiero estableciendo presupuestos y ajustándose a ellos. + ¡Puedes ver tu estructura de gastos por categorías! Pruébelo, toque el botón Gastos gris/negro justo debajo de su saldo. + Gráfico circular de gastos + Danos tu opinión de Ivy Wallet + ¡Danos tu opinión! Ayude a Ivy Wallet a mejorar y crecer escribiéndonos una reseña. ¡Los elogios, las ideas y las críticas son bienvenidos! Hacemos nuestro mejor esfuerzo.\n\nSaludos,\nEquipo Ivy + Ayúdanos a crecer para que podamos invertir más en desarrollo y mejorar la aplicación para ti. Al compartir Ivy Wallet, hará felices a dos desarrolladores y también ayudará a un amigo a tomar el control de sus finanzas. + Compartir con amigos + Puede generar informes para obtener información detallada sobre sus ingresos y gastos. Filtre sus transacciones por tipo, período de tiempo, categoría, cuentas, cantidad, palabras clave y más para obtener una mejor visión de sus finanzas. + Hacer un reporte + ¿Quieres mejorar Ivy Wallet? Escríbenos una reseña. Esa es la única forma en que podemos desarrollar lo que quiere y necesita. También nos ayuda a clasificarnos más alto en PlayStore para que podamos gastar dinero en el producto en lugar de en marketing.\n\nHacemos lo mejor que podemos.\nIvy Team + ¡Necesitamos tu ayuda! + Solo somos un diseñador y un desarrollador trabajando en la aplicación después de nuestros trabajos de 9 a 5. Actualmente, invertimos mucho tiempo y dinero para generar solo pérdidas y agotamiento. Si desea que sigamos desarrollando Ivy Wallet, compártalo con amigos y familiares.\n\nP.D. ¡Las reseñas de Google PlayStore también ayudan mucho! + ¡Ivy Wallet es de código abierto! + El código de Ivy Wallet está abierto y todos pueden verlo. Creemos que la transparencia y la ética son imprescindibles para todo producto de software. Si te gusta nuestro trabajo y quieres mejorar la aplicación, puedes contribuir en nuestro repositorio público de Github. + Contribuir + Ajustar el saldo + Autenticación requerida + Demuestra que tienes acceso a este dispositivo para desbloquear la aplicación. + Presupuesto total + Presupuesto de la categoría + Presupuesto multicategoría (%1$s) + PRESTADA + PRESTADO + Enero + Febrero + Marzo + Abril + Mayo + Junio + Julio + Agosto + Septiembre + Octubre + Noviembre + Diciembre + días + día + semanas + semana + meses + mes + años + año + + Trata las transferencias de cuentas como ingresos o gastos en la pantalla Cuentas + Hogar + Generando reporte… + " Ordenar por" + Saltar todo + Confirmar saltar todo + ¿Está seguro de saltar todas las transacciones planeadas caducadas? + Cambiar a modo sin conexión + ¡ADVERTENCIA! Esta acción eliminará todos sus datos almacenados en la nube para %1$s PERMANENTEMENTE, los datos sin conexión almacenados en su aplicación local permanecerán. + ¿Eliminar todos los datos almacenados en la nube? + Experimental + Ajustes experimentales + Wallet balance + diff --git a/resources/src/main/res/values-hi/strings.xml b/resources/src/main/res/values-hi/strings.xml new file mode 100644 index 0000000..8dd5425 --- /dev/null +++ b/resources/src/main/res/values-hi/strings.xml @@ -0,0 +1,440 @@ + + + खाता + इस महीने की आय + इस महीने के खर्च + (छोड़ा गया) + आय + खर्च + ऐप लॉक + कुल: %1$s %2$s + ऐप में प्रवेश करने के लिए प्रमाणित करें + खोले + वर्तमान राशि + नियोजित भुगतान के बाद शेष राशि + जोड़े + ट्रांज़ैक्शन सिंक करे + ट्रांज़ैक्शन सिंक हो रहे हैं + बैंक सिंक चालू किया गया: + ग्राहक निकालें + बजट जोड़ें + कोई बजट नहीं + आपके पास कोई बजट सेट नहीं है। जोड़ने के लिए + बटन पर टैप करें। + बजट + %1$s %2$s श्रेणियों के लिए + %1$s %2$s ऐप बजट + बजट की जानकारी: %1$s / %2$s + बजट की जानकारी: %1$s%2$s + कैटेगरी जोड़े + खर्च + खर्च की गिनती + आय + आय की गिनती + बैलेंस चार्ट + बैलेंस %1$s + चार्ट + अवधि: + श्रेणियाँ + CSV फ़ाइल एक्सपोर्ट करें + मानक विकल्पों के साथ CSV फ़ाइल एक्सपोर्ट करें + कृपया मानक विकल्पों का उपयोग करें और हेडर शामिल करना सुनिश्चित करें। + इम्पोर्ट कैसे करें + खोलें + स्टेप्स + कैसे करें + वीडियो + आर्टिकल + CSV फ़ाइल अपलोड करें + जानकारी एक्सपोर्ट करे + CSV/ZIP फ़ाइल अपलोड करें + फ़ाइल में एक्सपोर्ट करें + एक्सेल फ़ाइल एक्सपोर्ट करें + XLS को CSV में बदलें + !नोट: यदि निर्यात की गई फ़ाइल में .xls एक्सटेंशन नहीं है, तो फ़ाइल का नाम बदलकर मैन्युअल रूप से जोड़ें। + ऑनलाइन CSV कनवर्टर फ्री + अपने ईमेल के प्रमोशन और स्पैम फ़ोल्डर देखें + ईमेल के साथ संलग्न \"transactions_export…\" फ़ाइल डाउनलोड करें। + यदि आपके पास एक से अधिक करेंसी हैं, तो आपको प्रत्येक \"transactions_export…\" फ़ाइल को डाउनलोड करना होगा और इसे Ivy में इम्पोर्ट करना होगा। + इम्पोर्ट किया गया + कृपया प्रतीक्षा करें + CSV फ़ाइल इम्पोर्ट किया जा रहा हैं + सफल + असफल + इम्पोर्टेड + %1$d लेनदेन + %1$d खाते + %1$d श्रेणियां + असफल + CSV फ़ाइल से %1$d पंक्तियों को पहचाना नहीं गया + पूर्ण + विवरण जोड़ें + विवरण + के लिए योजना बनाई + इसमें पैसे जोड़ें + के साथ भुगतान करें + से + खाता + प्रति + खाता जोड़ें + आय शीर्षक + व्यय शीर्षक + ट्रांसफर शीर्षक + व्यय + भुगतान की नियोजित तिथि जोड़ें + भुगतान करे + प्राप्त करे + मिटाने की पुष्टि करे + इस लेन-देन को हटाने से इसे लेन-देन इतिहास से हटा दिया जाएगा और शेष राशि को तदनुसार अपडेट कर दिया जाएगा। + खाता परिवर्तन की पुष्टि करें + नोट: आप किसी भिन्न मुद्रा वाले खाते से ऋण से जुड़े खाते को बदलने का प्रयास कर रहे हैं, आज की विनिमय दरों के आधार पर सभी ऋण रिकॉर्ड की पुन: गणना की जाएगी + पुष्टि करें + कृपया प्रतीक्षा करें, सभी ऋण रिकॉर्ड की पुन: गणना की जा रही है + पर बनाया गया + नमस्ते + नमस्ते %1$s + कैशफ़्लो: %1$s%2$s %3$s + लेनदेन खोजें + Ivy वॉलेट ओपन सोर्स हैं + बचत लक्ष्य + क्विक ऐक्सेस + सेटिंग्स + लाइट मोड + डार्क मोड + आटोमेटिक मोड + आयोजित भुगतान + शेयर Ivy + रिपोर्ट्स + लोन्स + करेंसी सेट करें + कोई लेनदेन नहीं + आपने %1$s के लिए कोई लेन-देन नहीं किया है। जोड़ने के लिए \"+\" बटन पर टैप करे। + लोन जोड़े + कोई लोन नहीं + आपके पास कोई लोन नहीं है। जोड़ने के लिए \"+\" बटन पर टैप करे। + नोट: इस लोन को हटाने से यह स्थायी रूप से हट जाएगा और इससे जुड़े सभी लोन रिकॉर्ड हटा दिए जाएंगे। + कृपया प्रतीक्षा करें, सभी लोन रिकॉर्ड की पुन: गणना की जा रही है + भुगतान किया गया + %1$s %2$s शेष + क़र्ज़ का ब्याज + %1$s %2$s भुगतान किया गया + रिकॉर्ड जोड़ें + ब्याज + कोई रिकॉर्ड नहीं + आपके पास इस लोन का कोई रिकॉर्ड नहीं है, रिकॉर्ड बनाने के लिए रिकॉर्ड जोड़ें पर टैप करें। + आय जोड़ें + खर्च जोड़ें + वर्णन उपलब्ध नहीं + %1$s\\%% + खाता ट्रांसफर + आपने %1$s के लिए कोई लेन-देन नहीं किया है। आप नीचे स्क्रॉल करके और शीर्ष पर आय जोड़ें या व्यय जोड़ें बटन पर टैप करके जोड़ सकते हैं। + नोट: इस खाते को हटाने से यह स्थायी रूप से हट जाएगा और इससे जुड़े सभी लेन-देन हट जाएंगे। + नोट: इस श्रेणी को हटाने से यह स्थायी रूप से हट जाएगी। + एडिट + लेनदेन + होम + नियोजित भुगतान जोड़ें + आय जोड़ें + व्यय जोड़ें + खाता स्थानांतरण + स्किप + नया जोड़ें + %1$s से + %1$s तक + सीमा + प्राइवेसी और डाटा कलेक्शन + हमारे नियम और शर्तों से सहमत होने के लिए स्वाइप करें + हमारे नियमों और शर्तों से सहमत + हमारी गोपनीयता नीति से सहमत होने के लिए स्वाइप करें + नियम और शर्तें + प्राइवेसी पालिसी + आपका क्या नाम है? + दाखिल करना + खाते जोड़ें + सुझाव + अगला + श्रेणियां जोड़ें + सुझाव + संग्रह + आपका व्यक्तिगत मनी मैनेजर + #ओपनसोर्स + एरर। पुन: प्रयास करें: %1$s + साइनिंग इन… + सफलता! + गूगल के साथ लॉगिन करें + ऑफलाइन खाता + Ivy क्लाउड पर अपना डेटा सिंक करें + डेटा अखंडता और सुरक्षा की गारंटी नहीं है! + या ऑफ़लाइन खाते से दर्ज करें + + साइन इन करके, आप हमारे %1$s और %2$s से सहमत हो रहे है। + CSV फाइल इम्पोर्ट करे + Ivy या किसी अन्य ऐप से + बैकअप फ़ाइल इम्पोर्ट करें + नए से शुरू करें + भुगतान प्रकार सेट करें + हर %1$d %2$s को दोहराता है + हटाए गए + क्या आप सुनिश्चित हैं कि आप सभी अतिदेय नियोजित लेनदेन को छोड़ना चाहते हैं? + सभी को छोड़ने की पुष्टि करें + सभी को छोड़ें + अनुसार क्रमबद्ध करें + रिपोर्ट तैयार की जा रही है… + होम + + वर्ष + वर्षों + महीना + महीने + सप्ताह + हफ्तों + दिन + दिन + दिसंबर + नवंबर + अक्टूबर + सितंबर + अगस्त + जुलाई + जून + मई + अप्रैल + मार्च + फ़रवरी + जनवरी + रोज़ा + उधार + बहु-श्रेणी (%1$s) बजट + श्रेणी बजट + कुल बजट + ऐप को अनलॉक करने के लिए अपनी पुष्टि करें + प्रमाणीकरण आवश्यक + बैलेंस एडजस्ट करें + योगदान करें + Ivy वॉलेट का कोड ओपन सोर्स है और हर कोई इसे देख सकता है। हमारा मानना ​​है कि पारदर्शिता और नैतिकता हर सॉफ्टवेयर उत्पाद के लिए जरूरी है। यदि आप हमारे काम को पसंद करते हैं और ऐप को बेहतर बनाना चाहते हैं तो आप हमारे सार्वजनिक गिटहब रिपॉजिटरी में योगदान कर सकते हैं। + Ivy वॉलेट ओपन सोर्स हैं + हम सारे डिज़ाइनर और डेवलपर हैं जोकि 9–5 नौकरियों के बाद ऐप पर काम कर रहे हैं। बहुत समय और पैसा लगाने के बाद भी वर्तमान में हमे केवल नुकसान और थकावट हो रही है। यदि आप चाहते हैं कि हम Ivy वॉलेट विकसित करते रहें, तो कृपया इसे मित्रों और परिवार के साथ शेयर करें। P.S. Google PlayStore के रिव्यु भी बहुत मदद करते हैं! + हमें आपकी सहायता की आवश्यकता है! + रिपोर्ट बनाये + दोस्तों के साथ शेयर करें + Ivy वॉलेट को रिव्यु करें + व्यय पाई चार्ट + आप श्रेणियों के आधार पर अपने खर्चे की संरचना देख सकते हैं! इसे आज़माएं, अपनी शेष राशि के ठीक नीचे ग्रे/काले व्यय बटन पर टैप करें। + आइवी वॉलेट न केवल आपको अपने खर्चों को निष्क्रिय रूप से ट्रैक करने में मदद करता है, बल्कि बजट निर्धारित करके और उन पर टिके रहकर आपके वित्तीय भविष्य को भी सक्रिय रूप से तैयार करता है। + बजट सेट करें + विजेट जोड़ें + क्या आपको पता था? + अपने सब्सक्रिप्शन, किराया, वेतन इत्यादि जैसे आवर्ती लेनदेन की ट्रैकिंग को स्वचालित करें। यह जानकर अपने वित्त से आगे रहें कि आपको कितना भुगतान करना है/अग्रिम रूप से प्राप्त करना है। + अपना पहला नियोजित भुगतान बनाएं + खातों के लिए + अपना इनिशियल बैलेंस एडजस्ट करें + खेल + पालतू जानवर + शिक्षा + सुंदरता + कपड़े + आत्म विकास + स्वास्थ्य + यात्रा + फ़ूड आर्डर + सामाजिक जीवन + परिवार + रेस्टोरेंट + कार्य + कार + निवेश + स्वास्थ्य + उपहार + खरीदारी + मनोरंजन + किराने का सामान + यातायात + + + बैंक + नकद + क्या आपने आज अपना लेन-देन रिकॉर्ड किया है? 🏁 + क्या आपने आज अपने खर्चों को ट्रैक किया? 💸 + क्या आपने आज कोई लेन-देन किया है? 🏁 + प्रमाणीकरण विफल हुआ। + प्रमाणीकरण सफल रहा! + एक्स्पायर्ड + कल, %1$s + कल, %1$s + आज, %1$s + पिछले 7 दिन + पिछले 4 सप्ताह + पिछले 6 महीने + पिछले 12 महीने + इसे एक वाक्य में समझाइए। (मार्कडाउन सपोर्ट) + आपको किस चीज़ की जरूरत है? + सबमिट + मै रिपीट करता है + पर शुरू होता है + कई बार + एक बार + योजना बनाएं + नाम एडिट करें + नोट: आप लोन रिकॉर्ड से जुड़े खाते को किसी भिन्न करेंसी वाले खाते से बदलने का प्रयास कर रहे है। राशि की गणना आज की विनिमय दरों के आधार पर की जाएगी + क्या आप वाकई %1$s रिकॉर्ड हटाना चाहते हैं? + रिकॉर्ड राशि दर्ज करें + आज की मुद्रा विनिमय दरों के साथ राशि की पुनर्गणना की जायेगी + रुचि के रूप में चिह्नित करें + नोट + नया रिकॉर्ड + रिकॉर्ड एडिट करें + पैसा उधार दें + पैसे उधार लें + लोन प्रकार + नोट: आप किसी भिन्न करेंसी वाले खाते से लोन से जुड़े खाते को बदलने का प्रयास कर रहे हैं, आज की विनिमय दरों के आधार पर सभी लोन रिकॉर्ड की पुन: गणना की जाएगी + लोन राशि दर्ज करें + मुख्य लेनदेन बनाएँ + संबद्ध खाता + लोन का नाम + नया लोन + लोन एडिट करें + क्रिएट करें + जोड़ें + सेव करें + मिटाये + क्रिप्टो सपोर्ट करता है + महीने की आरंभ तिथि चुनें + आल टाइम का चयन करें + आल टाइम का चयन रद्द करें + या आल टाइम + या आखिरी में + तिथि जोड़ें + या कस्टम रेंज + महीना चुनें + आइकन चुनें + बचत लक्ष्य सेव करें + क्या आप वाकई %1$s बजट हटाना चाहते हैं? + कुल बज़ट + बजट का नाम + बजट बनाएं + बजट एडिट करें + कीवर्ड + पुन: आर्डर करें + रंग पसंद करें + विनिमय दर + क्रिप्टो + पूर्व-चयनित + खोजें (USD, EUR, GBP, BTC, आदि) + चयनित + ट्रांसफर + लेन-देन प्रकार सेट करें + बजट इतने से अधिक हो गया + बफर इतने से अधिक हो गया + खर्च करने के लिए छोड़ दिया गया + CSV को एक्सपोर्ट करें + अनाम + सिंक विफ। सिंक करने के लिए टैप करें + सिंक करने के लिए टैप करें + क्लाउड से डाटा सिंक कर दिया गया + सिंक किया जा रहा है… + लॉग इन करें + लॉग आउट + खाता + प्रोजेक्ट योगदानकर्ता + सहयोग टीम से संपर्क करें + सुविधा के लिए अनुरोध करें + रोडमैप + सहायता केंद्र + Ivy टेलीग्राम + महीने की शुरुआत की तारीख + कृपया प्रतीक्षा करें, डेटा एक्सपोर्ट किया जा रहा है + डेटा एक्सपोर्ट किया जा रहा है + अंतिम चेतावनी! डिलीट पर क्लिक करने के बाद आपका डेटा हमेशा के लिए चला जाएगा। + आपका सारा डेटा + \'%1$s\' के स्थायी डिलीशन की पुष्टि करें + आपका खाता + चेतावनी! यह क्रिया %1$s के सभी डेटा को स्थायी रूप से हटा देगी और आप इसे पुनर्प्राप्त नहीं कर पाएंगे। + सारा यूजर डेटा डिलीट करें? + सारा यूजर डेटा डिलीट करें + खतरा क्षेत्र + प्रोडक्ट + Ivy वॉलेट शेयर करें + हमें Google Play पर रेट करें + अन्य + ५ सेकंड के लिए शेष राशि दिखाने के लिए हिडन बैलेंस पर क्लिक करें + बैलेंस छुपाएं + सूचनाएं दिखाएं + लॉक ऐप + एप्लिकेशन सेटिंग + इम्पोर्ट आंकड़ा + बैकअप डेटा + + %1$s क्वेरी के लिए आपका कोई लेन-देन नहीं है। + एक्सपोर्ट करें + फ़िल्टर सेट करें + कैरेक्टर सेट: UTF-८ दशमलव विभाजक: दशमलव बिंदु \'.\' सीमांकक वर्ण: अल्पविराम \',\' + आप सहमत हैं हमारी प्राइवेसी पॉलिसी से + Ivy के साथ अपनी आय, खर्च और बजट को ट्रैक करें।\nसहज ज्ञान युक्त यूआई, आवर्ती और नियोजित भुगतान, कई खातों का प्रबंधन, श्रेणियों में लेनदेन का आयोजन, सार्थक आँकड़े, CSV को export करें और बहुत कुछ। + वॉलेट पर्सनॅलिजे करने के लिए अपना नाम दर्ज करें + आपका डेटा स्थानीय रूप से सहेजा जाएगा (केवल आपके फ़ोन पर) और क्लाउड के साथ समन्वयित नहीं किया जाएगा। यदि आप ऐप को अनइंस्टॉल करते हैं या अपना डिवाइस बदलते हैं तो आप इसे खोने का जोखिम उठाते हैं। यदि आप निर्णय लेते हैं तो आप बाद में कभी भी सिंक शुरू कर सकते हैं। + दूसरे से बैकअप फ़ाइल इम्पोर्ट करने में 5 मिनट तक का समय लग सकता है। आप चाहें तो बाद में कभी भी अपना डेटा इम्पोर्ट कर सकते हैं। + इस नियोजित भुगतान को हटाने से इससे जुड़े सभी गैर-भुगतान आगामी या अतिदेय लेनदेन हट जाएंगे। + नियोजित शुरुआत + के लिए योजना बनाई + null + स्टार्ट %1$s + भुगतान जोड़ें + एकमुश्त भुगतान + आवर्ती भुगतान + कोई नियोजित भुगतान नहीं + आपके पास कोई नियोजित भुगतान नहीं है। \nएक जोड़ने के लिए नीचे दिए गए \'⚡\' बटन दबाएं। + नियोजित भुगतान + आज + बिता हुआ दिन + कल आने वाला दिन + %1$s को देय + आगामी + अतिदेय + खर्च + आय + खाता संपादित करें + नया खाता + खाते का नाम + खाता शामिल करें + खाता शेष दर्ज करें + करेंसी चुनिये + कैलकुलेटर + गणना (+-/*=) + श्रेणी एडिट करें + श्रेणी बनाएं + श्रेणी का नाम + श्रेणी चुने + यहां कोई भी विवरण दर्ज करें + फ़िल्टर क्लियर करें + फ़िल्टर + फिल्टर लागू करें + प्रकार से + आय + समय सीमा + समय सीमा चुनें + खाते (%1$d) + श्रेणियाँ (%1$d) + सभी का चयन करे + सभी क्लियर करें + राशि (वैकल्पिक) + कीवर्ड (वैकल्पिक) + शामिल + कीवर्ड जोड़ें + शामिल नहीं + आपके पास इस फ़िल्टर के लिए कोई लेन-देन नहीं है। + कोई फिल्टर नहीं + रिपोर्ट जनरेट करने के लिए आपको पहले एक मान्य फ़िल्टर सेट करना होगा। + Revolut + किसी खाते पर टैप करें -> उसके शेष पर टैप करें -> वर्तमान शेष राशि दर्ज करें। बस इतना ही!]]> + Ivy वॉलेट में एक विजेट है जो आपको अपने होम स्क्रीन से 1-क्लिक के साथ आय/व्यय/हस्तांतरण लेनदेन जोड़ने देता है\n\n नोट: यदि विजेट जोड़ने वाला बटन काम नहीं करता है, तो कृपया इसे अपने लॉन्चर के विजेट मेनू से मैन्युअल रूप से जोड़ें। + हमें अपनी प्रतिक्रिया दें! हमें एक समीक्षा लिखकर Ivy वॉलेट को बेहतर बनाने और बढ़ने में मदद करें। प्रशंसा, विचार और आलोचनाओं का स्वागत है! हम अपनी तरफ से अच्छे प्रयास करते रहेंगे हैं।\nप्रोत्साहित करना,\nआइवी टीम + हमें बढ़ने में मदद करें ताकि हम विकास में अधिक निवेश कर सकें और ऐप को आपके लिए बेहतर बना सकें। आइवी वॉलेट साझा करके आप दो डेवलपर्स को खुश करेंगे और एक दोस्त को उनके वित्त पर नियंत्रण रखने में भी मदद करेंगे। + आप अपनी आय और खर्च के बारे में गहन जानकारी प्राप्त करने के लिए रिपोर्ट तैयार कर सकते हैं। अपने वित्त के बारे में बेहतर दृष्टिकोण प्राप्त करने के लिए अपने लेन-देन को प्रकार, समय अवधि, श्रेणी, खातों, राशि, कीवर्ड आदि के आधार पर फ़िल्टर करें। + आइवी वॉलेट को बेहतर बनाना चाहते हैं? हमें एक समीक्षा लिखें। आप जो चाहते हैं उसे विकसित करने का यही एकमात्र तरीका है। साथ ही हमें PlayStore में उच्च रैंक देने में मदद करें ताकि हम मार्केटिंग के बजाय उत्पाद पर पैसा खर्च कर सकें।\\nहम अपनी तरफ से हमेशाअच्छा करते रहेंगे।\\n Ivy टीम + खाता स्थानान्तरण को खाता स्क्रीन में आय या व्यय के रूप में मानता है + ऑफ़लाइन मोड में स्विच करें + चेतावनी! यह क्रिया %1$s के लिए आपके सभी क्लाउड-संग्रहीत डेटा को स्थायी रूप से हटा देगी, आपके स्थानीय ऐप में संग्रहीत ऑफ़लाइन डेटा बना रहेगा। + सभी क्लाउड-संग्रहीत डेटा हटाएं? + Experimental + प्रायोगिक सेटिंग्स + वॉलेट बैलेंस + \ No newline at end of file diff --git a/resources/src/main/res/values-it/strings.xml b/resources/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..d97869f --- /dev/null +++ b/resources/src/main/res/values-it/strings.xml @@ -0,0 +1,440 @@ + + + Conti + Totale: %1$s %2$s + ENTRATE DEL MESE + USCITE DEL MESE + (escluso) + ENTRATE + USCITE + APP BLOCCATA + Autenticati per entrare nell\'app + Sblocca + SALDO ATTUALE + SALDO DOPO I PAGAMENTI PIANIFICATI + Connetti + Sincronizza transazioni + Sincronizzazione transazioni… + Sincronizzazione della banca attiva: + Rimuovi client + Aggiungi budget + Nessun budget + Non hai nessun budget impostato.\nTocca \"+ Aggiungi budget\" per aggiungerne uno. + Budgets + %1$s %2$s per categorie + %1$s %2$s budget dell\'app + Informazioni sul budget: %1$s / %2$s + Info sul budget %1$s%2$s + Aggiungi categoria + Uscite + Numero uscite + Entrate + Numero entrate + Grafico del saldo + SALDO %1$s + Grafici + Periodo: + Categorie + Esporta file CSV + Esporta il file CSV con le opzioni standard + Si prega di utilizzare le opzioni standard e assicurarsi di includere le intestazioni. + Come importare + apri + Passaggi + Come fare + Video + Articolo + Carica il file CSV + Esporta i dati + Carica il file CSV/ZIP + Esporta su file + Character set: UTF-8\nDecimal separator: Decimal point \'.\'\nDelimiter character: Comma \',\' + Esporta il file Excel + Converti XLS in CSV + !NOTA: Se il file esportato non ha l\'estensione \".xls\", aggiungila rinominando manualmente il file. + Convertitore online di CSV GRATIS + Controlla le cartelle \"Promozioni\" e \"Spam\" della tua email + Scarica il file \"transactions_export…\" allegato all\'email. + Se hai più di una valuta dovrai scaricare ogni file \"transactions_export…\" e importarlo in Ivy. + Importa da + Attendere prego + Importazione del file CSV + Completato + Errore + Importato + %1$d transazioni + %1$d conti + %1$d categorie + Non riuscito + %1$d righe dal file CSV non riconosciute + Fine + Aggiungi descrizione + Descrizione + Pianificato per + Aggiungi denaro a + Paga con + Da + Conto + A + Aggiungi conto + Titolo entrata + Titolo spesa + Titolo del trasferimento + Uscite + Aggiungi la data prevista del pagamento + Paga + Ricevi + Conferma l\'eliminazione + L\'eliminazione di questa transazione la rimuoverà dalla cronologia delle transazioni e aggiornerà di conseguenza il saldo. + Conferma cambio contro + Nota: Stai cercando di modificare il conto associato al prestito con un conto di valuta diversa, \nTutti i record del prestito saranno ricalcolati in base ai tassi di cambio di oggi + Conferma + Attendi, tutti i prestiti stanno venendo ricalcolati + Creato il + Ciao + Ciao %1$s + Flusso di cassa: %1$s%2$s %3$s + Cerca transazioni + Ivy Wallet è open-source + Obiettivo di risparmio + Accesso rapido + Impostazioni + Modalità chiara + Modalità scura + Modalità\nautomatica + Pagamenti\nPianificati + Condividi Ivy + Resoconti + Prestiti + Imposta la valuta + Nessuna transazione + Non hai nessuna transazione per %1$s.\nPuoi aggiungerne una toccando il tasto \"+\". + Aggiungi prestito + Nessun prestito + Non hai alcun prestito.\nTocca il tasto \"+ Aggiungi prestito\" per aggiungerne uno. + Nota: L\'eliminazione di questo prestito lo rimuoverà definitivamente ed eliminerà tutte le registrazioni associate. + Attendi, tutte le registrazioni dei prestiti stanno venendo ricalcolate + Pagato + %1$s %2$s rimasti + Interessi del prestito + %1$s %2$s pagati + Aggiungi registrazione + Interessi + Nessuna registrazione + Non hai ancora nessuna registrazione per questo prestito. Tocca \"Aggiungi registrazione\" per crearne uno. + Aggiungi entrata + Aggiungi uscita + Non specificato + %1$s\%% + Trasferimenti Conti + Non hai nessuna transazione per %1$s.\nÈ possibile aggiungerne una scorrendo verso il basso e toccando il pulsante \"Aggiungi entrata\" o \"Aggiungi uscita\" in alto. + Nota: L\'eliminazione di questo conto lo rimuoverà definitivamente ed eliminerà tutte le registrazioni associate. + Nota: L\'eliminazione di questa categoria la rimuoverà permanentemente. + Modifica + transazioni + Home + Aggiungi pagamento pianificato + AGGIUNGI ENTRATA + AGGIUNGI SPESA + TRASFERIM. CONTO + Salta + Aggiungi nuovo + Da %1$s + A %1$s + Range + Privacy e\nraccolta di dati + Scorri per accettare i nostri Termini e condizioni + In accordo con i nostri Termini e condizioni + Scorri per accettare la nostra informativa sulla privacy + In accordo con la nostra Informativa sulla privacy + Termini e condizioni + Informativa sulla privacy + Traccia le tue entrate, le uscite e i budget con Ivy.\n\nIU intuitiva, pagamenti ricorrenti e pianificati, gestione di più conti, organizzazione delle transazioni in categorie, statistiche eloquenti, esportazione in CSV e molto altro. + Inserisci il tuo nome\nper personalizzare il tuo\nportafoglio + Come ti chiami? + Invia + Aggiungi i conti + Suggerimenti + Prossimo + Aggiungi le categorie + Suggerimenti + Imposta + Il tuo gestore di denaro personale + #opensource + Errore. Prova di nuovo: %1$s + Accesso in corso… + Completato! + Accedi con Google + Account offline + SINCRONIZZA I TUOI DATI SU IVY CLOUD + L\'integrità e la protezione dei dati non sono garantite! + O PROCEDI CON UN ACCOUNT OFFLINE + I tuoi dati verranno salvati localmente (solo sul tuo telefono) e non verranno sincronizzati nel cloud. Rischi di perdete i dati se disinstalli l\'applicazione o se cambi il tuo dispositivo. È sempre possibile attivare la sincronizzazione in seguito se decidi di farlo. + + Accedendo accetti i nostri %1$s e la nostra %2$s. + Importa un file CSV + da Ivy o da un\'altra app + Importare un file di backup da un\'altra app può richiedere fino a 5 minuti. È sempre possibile importare i dati in un secondo momento, se lo desideri. + Importa un file di backup + Inizia da zero + L\'eliminazione di questo pagamento pianificato eliminerà anche tutte le transazioni associate non pagate in arrivo o in ritardo. + Imposta il tipo di pagamento + Inizio pianificato il + SI RIPETE OGNI %1$d %2$s + eliminato + "PIANIFICATO PER " + n.d. + "INIZIA %1$s " + Aggiungi pagamento + Pagamenti una tantum + Pagamenti ricorrenti + Nessun pagamento pianificato + Non hai nessun pagamento pianificato.\nPremi il tasto \'⚡\' in basso per aggiungerne uno. + Pagamenti pianificati + Oggi + Ieri + Domani + Scade %1$s + In arrivo + Scaduti + uscite + entrate + Modifica conto + Nuovo conto + Nome del conto + Includi conto + Inserisci il saldo del conto + Scegli una valuta + Calcolatrice + Calcolo (+-/*=) + Modifica categoria + Crea una categoria + Nome della categoria + Scegli la categoria + Inserisci qui ogni dettaglio + Cancella filtro + Filtro + Applica il filtro + Per Tipo + Entrate + Periodo di Tempo + Seleziona l\'intervallo di tempo + Conti (%1$d) + Categorie (%1$d) + Cancella tutto + Seleziona tutto + Importo (facoltativo) + Parole chiave (opzionale) + INCLUDI + Aggiungi una parola chiave + ESCLUDI + Non hai transazioni per il tuo filtro. + Nessun Filtro + Per generare un resoconto devi prima impostare un filtro valido. + Imposta Filtro + Esporta + Non hai nessuna transazione corrispondente alla ricerca di \"%1$s\". + + Backup dei dati + Importa i dati + Impostazioni dell\'App + Blocca l\'app + Mostra le notifiche + Nascondi saldo + Fai clic sul saldo nascosto per visualizzarlo per 5s + Altro + Valutaci su Google Play + Condividi Ivy Wallet + Prodotto + Zona pericolosa + Elimina tutti i dati utente + Eliminare tutti i dati utente? + ATTENZIONE! Questa azione eliminerà tutti i dati per %1$s PERMANENTEMENTE e non sarai in grado di recuperarli. + il tuo account + Conferma l\'eliminazione permanente per \'%1$s\' + tutti i tuoi dati + AVVISO FINALE! Dopo aver cliccato su \"Elimina\" i tuoi dati saranno persi per sempre. + Esportando i dati + Attendi, esportazione dei dati in corso + Data di inizio del mese + Ivy Telegram + Centro assistenza + Roadmap + Richiedi una funzionalità + Contatta il supporto + Contributori del progetto + ACCOUNT + Esci + Accedi + Sincronizzazione… + Dati sincronizzati nel cloud + Tocca per sincronizzare + Sincronizzazione non riuscita. Tocca per sincronizzare + Anonimo + Esporta in CSV + Rimangono da spendere + Budget superato di + Margine superato di + Imposta il tipo di transazione + Trasferimento + Selezionato + Cerca (USD, EUR, GBP, BTC, ecc) + Pre-selezionato + Crypto + Tasso Di Cambio + Scegli il colore + Riordina + Parola chiave + Modifica il budget + Crea un budget + Nome del budget + IMPORTO DEL BUDGET + Sei sicuro di voler eliminare il budget \"%1$s\"? + Modifica l\'obiettivo di risparmio + Scegli l\'icona + Scegli il mese + o un intervallo personalizzato + Aggiungi una data + o nell’ultimo periodo di + o tutto il tempo + Deseleziona tutto il tempo + Seleziona tutto il tempo + Scegli la data di inizio del mese + supporta criptovalute + Elimina + Salva + Aggiungi + Crea + Modifica prestito + Nuovo prestito + Nome del prestito + Account Associato + Crea una transazione principale + INSERISCI L\'IMPORTO DEL PRESTITO + "Nota: Stai cercando di modificare il conto associato al prestito con un conto di valuta diversa, \nTutti i record del prestito saranno ricalcolati in base ai tassi di cambio di oggi " + Tipo di prestito + Ricevi in prestito + Dai in prestito + Modifica registrazione + Nuovo record + Note + Segna come interessi + Ricalcola l\'ammontare con i tassi di cambio di oggi + INSERISCI L\'IMPORTO DELLA REGISTRAZIONE + Sei sicuro di voler eliminare la registrazione \"%1$s\"? + "Nota: Stai cercando di modificare il conto associato alla registrazione del prestito con un conto di valuta diversa, \nL'importo sarà ricalcolato in base ai tassi di cambio di oggi " + Modifica il nome + Pianifica per + Una volta + Più volte + Inizia + Ripeti ogni + Invia + Di cosa hai bisogno? + Spiegalo in una frase in inglese. (è supportato il markdown) + Ultimi 12 mesi + Ultimi 6 mesi + Ultime 4 settimane + Ultimi 7 giorni + Oggi, %1$s + Ieri, %1$s + Domani, %1$s + Scaduto + Autenticazione riuscita! + Autenticazione non riuscita. + Hai effettuato delle transazioni oggi? 🏁 + Hai tenuto traccia delle tue spese oggi? 💸 + Hai registrato le tue transazioni oggi? 🏁 + Contanti + Banca + Revolut + + + Trasporti + Alimentari + Intrattenimento + Shopping + Regali + Salute + Investimenti + Auto + Lavoro + Ristorante + Famiglia + Vita Sociale + Cibo a domicilio + Viaggi + Fitness + Self-development + Vestiti + Bellezza + Istruzione + Animali + Sport + Sistema il tuo saldo iniziale + Visualizza i conti + Seleziona un conto -> Seleziona il suo saldo -> Digita il saldo attuale. Ecco fatto!]]> + Crea il tuo primo pagamento pianificato + Automatizza il monitoraggio delle transazioni ricorrenti come i tuoi abbonamenti, l\'affitto, lo stipendio, ecc. Stai al passo con le tue finanze sapendo già quanto devi pagare/ricevere in anticipo. + Lo sapevi? + Ivy Wallet ha un fantastico widget che consente di aggiungere ENTRATE/USCITE/TRASFERIMENTI con un clic dalla tua home\n\nNota: se il pulsante \"Aggiungi widget\" non funziona, aggiungilo manualmente dal menu dei widget del tuo launcher. + Aggiungi widget + Imposta un budget + Ivy Wallet non solo ti aiuta a monitorare passivamente le tue spese, ma crea anche pro-attivamente il tuo futuro finanziario impostando budget e seguendoli. + Puoi vedere lo schema delle tue spese diviso per categorie! Provalo, tocca il pulsante grigio e nero appena sotto il tuo saldo. + Grafico a torta delle uscite + Recensisci Ivy Wallet + Dacci il tuo feedback! Aiuta Ivy Wallet a migliorare e crescere scrivendoci una recensione. Complimenti, idee e critiche sono benvenuti! Facciamo del nostro meglio.\n\nA presto,\nIvy Team + Aiutaci a crescere in modo da poter investire di più nello sviluppo e rendere l\'applicazione migliore per te. Condividendo Ivy Wallet potrai rendere felici due sviluppatori e aiutare un amico a tenere sotto controllo le sue finanze. + Condividi con gli amici + Puoi generare dei resoconti per ottenere un\'analisi approfondita delle tue entrate e delle tue uscite. Filtra le tue transazioni per tipo, periodo di tempo, categoria, conto, parole chiave e non solo per ottenere una vista migliore sulle tue finanze. + Crea un report + Vuoi rendere migliore Ivy Wallet? Scrivici una recensione. Questo, per noi, è l\'unico modo di sviluppare quello che vuoi e che ti serve. Inoltre ci aiuta a ottenere una posizione migliore nel PlayStore così da poter investire sul prodotto pittosto che sul marketing.\n\nFacciamo del nostro meglio.\nIvy Team + Abbiamo bisogno del tuo aiuto! + Siamo solo un designer e uno sviluppatore che lavorano sull\'app dopo i nostri 9–5 lavori. Attualmente, investiamo molto tempo e denaro per generare solo perdite e sfinimento. Se vuoi che continuiamo a sviluppare Ivy Wallet per favore condividilo con amici e familiari.\n\nP.S. Le recensioni sul Google PlayStore aiutano molto! + Ivy Wallet è open-source! + Il codice di Ivy Wallet è aperto e tutti possono vederlo. Crediamo che la trasparenza e l\'etica siano fondamentali per ogni prodotto software. Se ti piace il nostro lavoro e vuoi migliorare l\'app, puoi contribuire nel nostro repository pubblico su Github. + Contribuisci + Sistema il saldo + Autenticazione richiesta + Dimostra di avere accesso a questo dispositivo per sbloccare l\'app. + Budget generale + Budget di categoria + Budget multi-categoria (%1$s) + RICEVUTO + PRESTATO + Gennaio + Febbraio + Marzo + Aprile + Maggio + Giugno + Luglio + Agosto + Settembre + Ottobre + Novembre + Dicembre + giorni + giorno + settimane + settimana + mesi + mese + anni + anno + + Tratta i trasferimenti di conto come entrate o uscite nella schermata dei conti + Casa + Generazione report… + Ordina per + Sei sicuro di voler ignorare tutti i pagamenti pianificati scaduti? + Conferma e ignora tutto + Ignora tutto + Passa alla modalità offline + ATTENZIONE! Questa azione eliminerà PERMANENTEMENTE tutti i dati salvati nel cloud per %1$s, i file offline salvati in locale nella tua app rimaranno. + Eliminare tutti i dati salvati nel cloud? + Sperimantale + Impostazioni Sperimentali + Wallet balance + diff --git a/resources/src/main/res/values-kn/strings.xml b/resources/src/main/res/values-kn/strings.xml new file mode 100644 index 0000000..4a7bfb1 --- /dev/null +++ b/resources/src/main/res/values-kn/strings.xml @@ -0,0 +1,440 @@ + + + ಖಾತೆಗಳು + ಒಟ್ಟು: %1$s %2$s + ಈ ತಿಂಗಳ ಆದಾಯ + ಈ ತಿಂಗಳ ಖರ್ಚು + (ಹೊರತುಪಡಿಸಿ) + ಆದಾಯ + ಖರ್ಚು + ಅಪ್ಲಿಕೇಶನ್ ಲಾಕ್ ಆಗಿದೆ + ಅಪ್ಲಿಕೇಶನ್ ಬಳಸಲು ಪ್ರಮಾಣೀಕರಿಸಿ + ತೆಗೆ + ಪ್ರಸ್ತುತ ಮೊತ್ತ + ಯೋಜಿತ ಪಾವತಿಗಳ ನಂತರದ ಮೊತ್ತ + ಸೇರಿಸು + ವಹಿವಾಟುಗಳನ್ನು ಸಿಂಕ್ ಮಾಡಿ + ವಹಿವಾಟುಗಳನ್ನು ಸಿಂಕ್ ಮಾಡಲಾಗುತ್ತಿದೆ… + ಬ್ಯಾಂಕ್ ಸಿಂಕ್ ಅನ್ನು ಸಕ್ರಿಯಗೊಳಿಸಲಾಗಿದೆ: + ಗ್ರಾಹಕರನ್ನು ತೆಗೆದುಹಾಕಿ + ಬಜೆಟ್ ಸೇರಿಸಿ + ಯಾವುದೇ ಬಜೆಟ್ ಇಲ್ಲ + ನಿಮ್ಮ ಬಳಿ ಯಾವುದೇ ಬಜೆಟ್ ಇಲ್ಲ.\nಬಜೆಟ್ ಸೇರಿಸಲು "+ಬಜೆಟ್ ಸೇರಿಸಿ" ಬಟನನ್ನು ಟ್ಯಾಪ್ ಮಾಡಿ. + ಬಜೆಟ್‌ಗಳು + %1$s %2$s ವರ್ಗಗಳಿಗೆ + %1$s %2$s ಅಪ್ಲಿಕೇಶನ್ ಬಜೆಟ್ + ಬಜೆಟ್ ಮಾಹಿತಿ: %1$s / %2$s + ಬಜೆಟ್ ಮಾಹಿತಿ: %1$s%2$s + ವರ್ಗವನ್ನು ಸೇರಿಸಿ + ಖರ್ಚು + ಖರ್ಚುಗಳ ಎಣಿಕೆ + ಆದಾಯ + ಆದಾಯದ ಎಣಿಕೆ + ಮೊತ್ತದ ನಕಾಶೆ + ಮೊತ್ತ %1$s + ನಕಾಶಗಳು + ಅವಧಿ: + ವರ್ಗಗಳು + CSV ಫೈಲ್ ಅನ್ನು ರಫ್ತು ಮಾಡಿ + ಪ್ರಮಾಣಿತ ಆಯ್ಕೆಗಳೊಂದಿಗೆ CSV ಫೈಲ್ ಅನ್ನು ರಫ್ತು ಮಾಡಿ + ದಯವಿಟ್ಟು ಪ್ರಮಾಣಿತ ಆಯ್ಕೆಗಳನ್ನು ಬಳಸಿ ಮತ್ತು ಹೆಡರ್‌ಗಳನ್ನು ಸೇರಿಸುವುದನ್ನು ಖಚಿತಪಡಿಸಿಕೊಳ್ಳಿ. + ಹೇಗೆ ಆಮದು ಮಾಡಿಕೊಳ್ಳುವುದು + ತೆರೆ + ಹಂತಗಳು + ಹೇಗೆ + ವೀಡಿಯೊ + ಲೇಖನ + CSV ಫೈಲ್ ಅನ್ನು ಅಪ್ಲೋಡ್ ಮಾಡಿ + ಡೇಟಾವನ್ನು ರಫ್ತು ಮಾಡಿ + CSV/ZIP ಫೈಲ್ ಅನ್ನು ಅಪ್‌ಲೋಡ್ ಮಾಡಿ + ಫೈಲ್‌ಗೆ ರಫ್ತು ಮಾಡಿ + ಕ್ಯಾರೆಕ್ಟರ್ ಸೆಟ್: UTF-೮\nದಶಮಾಂಶ ವಿಭಜಕ: ದಶಮಾಂಶ ಬಿಂದು \'.\'\nಡಿಲಿಮಿಟರ್ ಅಕ್ಷರ: ಅಲ್ಪವಿರಾಮ \',\' + ಎಕ್ಸೆಲ್ ಫೈಲ್ ಅನ್ನು ರಫ್ತು ಮಾಡಿ + XLS ಅನ್ನು CSV ಗೆ ಪರಿವರ್ತಿಸಿ + !ಗಮನಿಸಿ: ರಫ್ತು ಮಾಡಿದ ಫೈಲ್ ".xls" ವಿಸ್ತರಣೆಯನ್ನು ಹೊಂದಿಲ್ಲದಿದ್ದರೆ, ಫೈಲ್ ಅನ್ನು ಹಸ್ತಚಾಲಿತವಾಗಿ ಮರುಹೆಸರಿಸುವ ಮೂಲಕ ಅದನ್ನು ಸೇರಿಸಿ. + ಆನ್‌ಲೈನ್ CSV ಪರಿವರ್ತಕ ಫ್ರೀಯೇ + ನಿಮ್ಮ ಇಮೇಲ್‌ನ "ಪ್ರಚಾರಗಳು" ಮತ್ತು "ಸ್ಪ್ಯಾಮ್" ಫೋಲ್ಡರ್‌ಗಳನ್ನು ಪರಿಶೀಲಿಸಿ + ಇಮೇಲ್‌ಗೆ ಲಗತ್ತಿಸಲಾದ \"transactions_export…\" ಫೈಲ್ ಅನ್ನು ಡೌನ್‌ಲೋಡ್ ಮಾಡಿ. + ನೀವು ಒಂದಕ್ಕಿಂತ ಹೆಚ್ಚು ಕರೆನ್ಸಿಯನ್ನು ಹೊಂದಿದ್ದರೆ ನೀವು ಪ್ರತಿಯೊಂದು \"transactions_export…\" ಫೈಲ್ ಅನ್ನು ಡೌನ್‌ಲೋಡ್ ಮಾಡಬೇಕಾಗುತ್ತದೆ ಮತ್ತು ಅದನ್ನು Ivy ವ್ಯಾಲೆಟ್ ನಲ್ಲಿ ಆಮದು ಮಾಡಿಕೊಳ್ಳಬೇಕು. + ಎಲ್ಲಿಂದ ಆಮದು ಮಾಡಿಕೊಳ್ಳಬೇಕು + ದಯವಿಟ್ಟು ಸ್ವಲ್ಪ ನಿರೀಕ್ಷಿಸಿ + CSV ಫೈಲ್ ಅನ್ನು ಆಮದು ಮಾಡಲಾಗುತ್ತಿದೆ + ನೆರವೇರಿದೆ + ವಿಫಲವಾಯಿತು + ಆಮದು ಮಾಡಿಕೊಳ್ಳಲಾಗಿದೆ + %1$d ವಹಿವಾಟುಗಳು + %1$d ಖಾತೆಗಳು + %1$d ವರ್ಗಗಳು + ವಿಫಲವಾಯಿತು + %1$d CSV ಫೈಲ್‌ನಿಂದ ಈ ಸಾಲುಗಳನ್ನು ಗುರುತಿಸಲಾಗಿಲ್ಲ + ಮುಗಿದಿದೆ + ವಿವರಣೆಯನ್ನು ಸೇರಿಸಿ + ವಿವರಣೆ + ಇದಕ್ಕಾಗಿ ಯೋಜನೆ ರೂಪಿಸಲಾಗಿದೆ + ಹಣವನ್ನು ಸೇರಿಸಿ + ಇದರೊಂದಿಗೆ ಪಾವತಿಸಿ + ಇಂದ + ಖಾತೆ + ಗೆ + ಖಾತೆಯನ್ನು ಸೇರಿಸಿ + ಆದಾಯದ ಹೆಸರು + ಖರ್ಚಿನ ಹೆಸರು + ವರ್ಗಾವಣೆಯ ಹೆಸರು + ಖರ್ಚು + ಪಾವತಿಯ ಯೋಜಿತ ದಿನಾಂಕವನ್ನು ಸೇರಿಸಿ + ಪಾವತಿಸಿ + ಪಡೆಯಿರಿ + ಅಳಿಸುವಿಕೆಯನ್ನು ದೃಢೀಕರಿಸಿ + ಈ ವಹಿವಾಟನ್ನು ಅಳಿಸುವುದರಿಂದ ವಹಿವಾಟಿನ ಇತಿಹಾಸದಿಂದ ಅದನ್ನು ತೆಗೆದುಹಾಕಲಾಗುತ್ತದೆ ಮತ್ತು ಅದಕ್ಕೆ ಅನುಗುಣವಾಗಿ ಬ್ಯಾಲೆನ್ಸ್ ಅನ್ನು ನವೀಕರಿಸಲಾಗುತ್ತದೆ. + ಖಾತೆ ಬದಲಾವಣೆಯನ್ನು ದೃಢೀಕರಿಸಿ + ಗಮನಿಸಿ: ನೀವು ವಿವಿಧ ಕರೆನ್ಸಿಯ ಖಾತೆಯೊಂದಿಗೆ ಸಾಲದೊಂದಿಗೆ ಸಂಯೋಜಿತವಾಗಿರುವ ಖಾತೆಯನ್ನು ಬದಲಾಯಿಸಲು ಪ್ರಯತ್ನಿಸುತ್ತಿರುವಿರಿ, \nಇಂದಿನ ವಿನಿಮಯ ದರಗಳ ಆಧಾರದ ಮೇಲೆ ಎಲ್ಲಾ ಸಾಲದ ದಾಖಲೆಗಳನ್ನು ಮರು ಲೆಕ್ಕಾಚಾರ ಮಾಡಲಾಗುತ್ತದೆ. + ದೃಢೀಕರಿಸಿ + ದಯವಿಟ್ಟು ನಿರೀಕ್ಷಿಸಿ, ಎಲ್ಲಾ ಸಾಲದ ದಾಖಲೆಗಳನ್ನು ಮರು ಲೆಕ್ಕಾಚಾರ ಮಾಡಲಾಗುತ್ತಿದೆ + ಈ ಸಮಯದಲ್ಲಿ ರಚಿಸಲಾಗಿದೆ + ನಮಸ್ಕಾರ + ನಮಸ್ಕಾರ %1$s + ಹಣದ ಹರಿವು: %1$s%2$s %3$s + ವಹಿವಾಟುಗಳನ್ನು ಹುಡುಕಿ + Ivy ವ್ಯಾಲೆಟ್ ಮುಕ್ತ ಸಂಪನ್ಮೂಲ ಆಗಿದೆ + ಉಳಿತಾಯದ ಗುರಿ + ತ್ವರಿತ ಪ್ರವೇಶ + ಸಂಯೋಜನೆಗಳು + ಲೈಟ್ ಮೋಡ್ + ಡಾರ್ಕ್ ಮೋಡ್ + ಆಟೋ ಮೋಡ್ + ಯೋಜಿತ\nಪಾವತಿಗಳು + Ivy ಯನ್ನು ಶೇರ್ ಮಾಡಿ + ವರದಿಗಳು + ಸಾಲಗಳು + ಕರೆನ್ಸಿಯನ್ನು ಹೊಂದಿಸಿ + ಯಾವುದೇ ವಹಿವಾಟುಗಳಿಲ್ಲ + ನೀವು ಇಲ್ಲಿ ಯಾವುದೇ ವಹಿವಾಟುಗಳನ್ನು ಹೊಂದಿಲ್ಲ %1$s.\n ನೀವು \"+\" ಬಟನ್ ಅನ್ನು ಟ್ಯಾಪ್ ಮಾಡುವ ಮೂಲಕ ಸೇರಿಸಬಹುದು. + ಸಾಲವನ್ನು ಸೇರಿಸಿ + ಯಾವುದೇ ಸಾಲಗಳಿಲ್ಲ + ನೀವು ಯಾವುದೇ ಸಾಲವನ್ನು ಹೊಂದಿಲ್ಲ.\nಸೇರಿಸಲು \"+\" ಅನ್ನು ಟ್ಯಾಪ್ ಮಾಡಿ. + ಗಮನಿಸಿ: ಈ ಸಾಲವನ್ನು ಅಳಿಸುವುದರಿಂದ ಅದನ್ನು ಶಾಶ್ವತವಾಗಿ ತೆಗೆದುಹಾಕಲಾಗುತ್ತದೆ ಮತ್ತು ಅದರೊಂದಿಗೆ ಸಂಬಂಧಿಸಿದ ಎಲ್ಲಾ ಸಾಲದ ದಾಖಲೆಗಳನ್ನು ಅಳಿಸಲಾಗುತ್ತದೆ. + ದಯವಿಟ್ಟು ನಿರೀಕ್ಷಿಸಿ, ಎಲ್ಲಾ ಸಾಲದ ದಾಖಲೆಗಳನ್ನು ಮರು ಲೆಕ್ಕಾಚಾರ ಮಾಡಲಾಗುತ್ತಿದೆ + ಪಾವತಿಸಲಾಗಿದೆ + %1$s %2$s ಉಳಿಧಿಧೆ + ಸಾಲದ ಬಡ್ಡಿ + %1$s %2$s ಪಾವತಿಸಲಾಗಿದೆ + ದಾಖಲೆ ಸೇರಿಸಿ + ಬಡ್ಡಿ + ದಾಖಲೆಗಳಿಲ್ಲ + ಈ ಸಾಲಕ್ಕೆ ನೀವು ಯಾವುದೇ ದಾಖಲೆಗಳನ್ನು ಹೊಂದಿಲ್ಲ. ರಚಿಸಲು "ದಾಖಲೆ ಸೇರಿಸಿ" ಯನ್ನು ಟ್ಯಾಪ್ ಮಾಡಿ. + ಆದಾಯವನ್ನು ಸೇರಿಸಿ + ಖರ್ಚನ್ನು ಸೇರಿಸಿ + ಅನಿರ್ದಿಷ್ಟ + %1$s\%% + ಖಾತೆ ವರ್ಗಾವಣೆಗಳು + ಇದಕ್ಕಾಗಿ ನೀವು ಯಾವುದೇ ವಹಿವಾಟುಗಳನ್ನು ಹೊಂದಿಲ್ಲ %1$s.\nನೀವು ಕೆಳಗೆ ಸ್ಕ್ರೋಲ್ ಮಾಡುವ ಮೂಲಕ ಮತ್ತು ಮೇಲಿನ "ಆದಾಯ ಸೇರಿಸಿ" ಅಥವಾ "ಖರ್ಚು ಸೇರಿಸಿ" ಬಟನ್ ಅನ್ನು ಟ್ಯಾಪ್ ಮಾಡುವ ಮೂಲಕ ಸೇರಿಸಬಹುದು. + ಗಮನಿಸಿ: ಈ ಖಾತೆಯನ್ನು ಅಳಿಸುವುದರಿಂದ ಅದನ್ನು ಶಾಶ್ವತವಾಗಿ ತೆಗೆದುಹಾಕಲಾಗುತ್ತದೆ ಮತ್ತು ಅದರೊಂದಿಗೆ ಸಂಬಂಧಿಸಿದ ಎಲ್ಲಾ ವಹಿವಾಟುಗಳನ್ನು ಅಳಿಸಲಾಗುತ್ತದೆ. + ಗಮನಿಸಿ: ಈ ವರ್ಗವನ್ನು ಅಳಿಸುವುದರಿಂದ ಅದನ್ನು ಶಾಶ್ವತವಾಗಿ ತೆಗೆದುಹಾಕಲಾಗುತ್ತದೆ. + ತಿದ್ದು + ವಹಿವಾಟುಗಳು + ಹೋಮ್ + ಯೋಜಿತ ಪಾವತಿಯನ್ನು ಸೇರಿಸಿ + ಆದಾಯವನ್ನು ಸೇರಿಸಿ + ಖರ್ಚನ್ನು ಸೇರಿಸಿ + ಖಾತೆ ವರ್ಗಾವಣೆ + ಬಿಟ್ಟುಬಿಡಿ + ಹೊಸದನ್ನು ಸೇರಿಸಿ + ಈ ದಿನಾಂಕದಿಂದ %1$s + ಈ ದಿನಾಂಕದವರೆಗೆ %1$s + ವ್ಯಾಪ್ತಿ + ಪ್ರೈವಸಿ ಮತ್ತು\nಡೇಟಾ ಸಂಗ್ರಹಣೆ + ನಮ್ಮ ನಿಯಮಗಳು ಮತ್ತು ಷರತ್ತುಗಳನ್ನು ಒಪ್ಪಿಕೊಳ್ಳಲು ಸ್ವೈಪ್ ಮಾಡಿ + ನಮ್ಮ ನಿಯಮಗಳು ಮತ್ತು ಷರತ್ತುಗಳೊಂದಿಗೆ ಸಮ್ಮತಿಸಲಾಗಿದೆ + ನಮ್ಮ ಪ್ರೈವಸಿ ನೀತಿಯನ್ನು ಒಪ್ಪಿಕೊಳ್ಳಲು ಸ್ವೈಪ್ ಮಾಡಿ + ನಮ್ಮ ಪ್ರೈವಸಿ ನೀತಿಗೆ ಸಮ್ಮತಿಸಲಾಗಿದೆ + ನಿಯಮಗಳು ಮತ್ತು ಷರತ್ತುಗಳು + ಪ್ರೈವಸಿ ನೀತಿ + ಐವಿಯೊಂದಿಗೆ ನಿಮ್ಮ ಆದಾಯ, ಖರ್ಚು ಮತ್ತು ಬಜೆಟ್ ಅನ್ನು ಟ್ರ್ಯಾಕ್ ಮಾಡಿ.\n\nಅರ್ಥಗರ್ಭಿತ UI, ಮರುಕಳಿಸುವ ಮತ್ತು ಯೋಜಿತ ಪಾವತಿಗಳು, ಅನೇಕ ಖಾತೆಗಳನ್ನು ನಿರ್ವಹಿಸಿ, ವರ್ಗಗಳಲ್ಲಿ ವಹಿವಾಟುಗಳನ್ನು ಆಯೋಜಿಸಿ, ಅರ್ಥಪೂರ್ಣ ಅಂಕಿಅಂಶಗಳು, CSV ಗೆ ರಫ್ತು ಮತ್ತು ಇನ್ನೂ ಹೆಚ್ಚು + ನಿಮ್ಮ ವ್ಯಾಲೆಟ್ ಅನ್ನು ವೈಯಕ್ತೀಕರಿಸಲು\nನಿಮ್ಮ ಹೆಸರನ್ನು ನಮೂದಿಸಿ + ನಿಮ್ಮ ಹೆಸರೇನು? + ನಮೂದಿಸಿ + ಖಾತೆಗಳನ್ನು ಸೇರಿಸಿ + ಸಲಹೆ + ಮುಂದೆ + ವರ್ಗಗಳನ್ನು ಸೇರಿಸಿ + ಸಲಹೆಗಳು + ಸ್ಥಾಪಿಸಿ + ನಿಮ್ಮ ವೈಯಕ್ತಿಕ ಹಣ ನಿರ್ವಾಹಕ + #ಮುಕ್ತಸಂಪನ್ಮೂಲ + ದೋಷ ಸಂಭವಿಸಿದೆ. ದಯವಿಟ್ಟು ಪುನಃ ಪ್ರಯತ್ನಿಸಿ: %1$s + ಸೈನ್ ಇನ್ ಮಾಡಲಾಗುತ್ತಿದೆ… + ಯಶಸ್ವಿಯಾಗಿದೆ! + ಗೂಗಲ್ ನೊಂದಿಗೆ ಲಾಗಿನ್ ಮಾಡಿ + ಆಫ್ಲೈನ್ ಖಾತೆ + Ivy ಕ್ಲೌಡ್‌ನಲ್ಲಿ ನಿಮ್ಮ ಡೇಟಾವನ್ನು ಸಿಂಕ್ ಮಾಡಿ + ಡೇಟಾ ಸಮಗ್ರತೆ ಮತ್ತು ರಕ್ಷಣೆಗೆ ಖಾತರಿಯಿಲ್ಲ! + ಅಥವಾ ಆಫ್‌ಲೈನ್ ಖಾತೆಯೊಂದಿಗೆ ನಮೂದಿಸಿ + ನಿಮ್ಮ ಡೇಟಾವನ್ನು ಸ್ಥಳೀಯವಾಗಿ ಉಳಿಸಲಾಗುತ್ತದೆ (ನಿಮ್ಮ ಫೋನ್‌ನಲ್ಲಿ ಮಾತ್ರ) ಮತ್ತು ಕ್ಲೌಡ್‌ನೊಂದಿಗೆ ಸಿಂಕ್ ಆಗುವುದಿಲ್ಲ. ನೀವು ಅಪ್ಲಿಕೇಶನ್ ಅನ್ನು ಅನ್‌ಇನ್‌ಸ್ಟಾಲ್ ಮಾಡಿದರೆ ಅಥವಾ ನಿಮ್ಮ ಸಾಧನವನ್ನು ಬದಲಾಯಿಸಿದರೆ ನೀವು ಅದನ್ನು ಕಳೆದುಕೊಳ್ಳುವ ಅಪಾಯವಿದೆ. ನೀವು ನಿರ್ಧರಿಸಿದರೆ ನೀವು ಯಾವಾಗಲೂ ಸಿಂಕ್ ಅನ್ನು ಸಕ್ರಿಯಗೊಳಿಸಬಹುದು. + + ಸೈನ್ ಇನ್ ಮಾಡುವ ಮೂಲಕ, ನೀವು ನಮ್ಮೊಂದಿಗೆ ಸಮ್ಮತಿಸುತ್ತೀರಿ %1$s ಮತ್ತು %2$s. + CSV ಫೈಲ್ ಅನ್ನು ಆಮದು ಮಾಡಿ + Ivy ಅಥವಾ ಇನ್ನೊಂದು ಅಪ್ಲಿಕೇಶನ್‌ನಿಂದ + ಇನ್ನೊಂದರಿಂದ ಬ್ಯಾಕಪ್ ಫೈಲ್ ಅನ್ನು ಆಮದು ಮಾಡಿಕೊಳ್ಳಲು 5 ನಿಮಿಷಗಳವರೆಗೆ ತೆಗೆದುಕೊಳ್ಳಬಹುದು. ನೀವು ಬಯಸಿದಲ್ಲಿ ಯಾವಾಗಲೂ ನಿಮ್ಮ ಡೇಟಾವನ್ನು ಆಮದು ಮಾಡಿಕೊಳ್ಳಬಹುದು. + ಬ್ಯಾಕಪ್ ಫೈಲ್ ಅನ್ನು ಆಮದು ಮಾಡಿ + ಹೊಸದಾಗಿ ಪ್ರಾರಂಭಿಸಿ + ಈ ಯೋಜಿತ ಪಾವತಿಯನ್ನು ಅಳಿಸುವುದರಿಂದ ಅದಕ್ಕೆ ಸಂಬಂಧಿಸಿದ ಎಲ್ಲಾ ಪಾವತಿಸದ ಮುಂಬರುವ ಅಥವಾ ಮಿತಿಮೀರಿದ ವಹಿವಾಟುಗಳನ್ನು ಅಳಿಸಲಾಗುತ್ತದೆ. + ಪಾವತಿ ಪ್ರಕಾರವನ್ನು ಹೊಂದಿಸಿ + ನಲ್ಲಿ ಪ್ರಾರಂಭಿಸಲು ಯೋಜಿಸಲಾಗಿದೆ + ಪ್ರತಿ ಪುನರಾವರ್ತಿಸುತ್ತದೆ %1$d %2$s + ಅಳಿಸಲಾಗಿದೆ + "ಗಾಗಿ ಯೋಜಿಸಲಾಗಿದೆ " + ಊರ್ಜಿತವಾಗದ + "ಪ್ರಾರಂಭ ದಿನಾಂಕ %1$s " + ಪಾವತಿಯನ್ನು ಸೇರಿಸಿ + ಒಂದು ಬಾರಿಮಾಡುವ ಪಾವತಿಗಳು + ಮರುಕಳಿಸುವ ಪಾವತಿಗಳು + ಯಾವುದೇ ಯೋಜಿತ ಪಾವತಿಗಳಿಲ್ಲ + ನೀವು ಯಾವುದೇ ಯೋಜಿತ ಪಾವತಿಗಳನ್ನು ಹೊಂದಿಲ್ಲ.\nಒಂದನ್ನು ಸೇರಿಸಲು ಕೆಳಭಾಗದಲ್ಲಿರುವ \'⚡\' ಬಟನ್ ಅನ್ನು ಒತ್ತಿರಿ. + ಯೋಜಿತ ಪಾವತಿಗಳು + ಇಂದು + ನಿನ್ನೆ + ನಾಳೆ + ಈ ದಿನಾಂಕದಂದು ಬಾಕಿಯಿದೆ %1$s + ಮುಂಬರುವ + ಅವಧಿ ಮೀರಿದೆ + ಖರ್ಚು + ಆದಾಯ + ಖಾತೆಯನ್ನು ಎಡಿಟ್ ಮಾಡಿ + ಹೊಸ ಖಾತೆ + ಖಾತೆಯ ಹೆಸರು + ಖಾತೆಯನ್ನು ಸೇರಿಸಿ + ಖಾತೆಯ ಮೊತ್ತವನ್ನು ನಮೂದಿಸಿ + ಕರೆನ್ಸಿ ಆಯ್ಕೆಮಾಡಿ + ಗಣಕ ಯ೦ತ್ರ + ಗುಣಾಕಾರ (+-/*=) + ವರ್ಗವನ್ನು ಎಡಿಟ್ ಮಾಡಿ + ವರ್ಗವನ್ನು ರಚಿಸಿ + ವರ್ಗದ ಹೆಸರು + ವರ್ಗವನ್ನು ಆಯ್ಕೆಮಾಡಿ + ಯಾವುದೇ ವಿವರಗಳನ್ನು ಇಲ್ಲಿ ನಮೂದಿಸಿ + ಫಿಲ್ಟರ್ ಅನ್ನು ತೆರವುಗೊಳಿಸಿ + ಫಿಲ್ಟರ್ + ಫಿಲ್ಟರ್ ಅನ್ನು ಅನ್ವಯಿಸಿ + ಪ್ರಕಾರದ ಮೂಲಕ + ಆದಾಯಗಳು + ಸಮಯದ ಅವಧಿ + ಸಮಯ ಶ್ರೇಣಿಯನ್ನು ಆಯ್ಕೆಮಾಡಿ + ಖಾತೆಗಳು (%1$d) + ವರ್ಗಗಳು (%1$d) + ಎಲ್ಲವನ್ನೂ ತೆರವುಗೊಳಿಸಿ + ಎಲ್ಲವನ್ನೂ ಆಯ್ಕೆಮಾಡಿ + ಮೊತ್ತ (ಕಡಾಯವಲ್ಲದ) + ಕೀವರ್ಡ್‌ಗಳು (ಕಡಾಯವಲ್ಲದ) + ಒಳಗೊಂಡಿದೆ + ಕೀವರ್ಡ್ ಸೇರಿಸಿ + ಹೊರತುಪಡಿಸಿ + ನಿಮ್ಮ ಫಿಲ್ಟರ್‌ಗಾಗಿ ನೀವು ಯಾವುದೇ ವಹಿವಾಟುಗಳನ್ನು ಹೊಂದಿಲ್ಲ. + ಯಾವುದೇ ಫಿಲ್ಟರ್ ಇಲ್ಲ + ವರದಿಯನ್ನು ರಚಿಸಲು ನೀವು ಮೊದಲು ಮಾನ್ಯವಾದ ಫಿಲ್ಟರ್ ಅನ್ನು ಹೊಂದಿಸಬೇಕು. + ಫಿಲ್ಟರ್ ಅನ್ನು ಹೊಂದಿಸಿ + ರಫ್ತು ಮಾಡಿ + ನೀವು "%1$s" ಗೆ ಯಾವುದೇ ವಹಿವಾಟುಗಳನ್ನು ಹೊಂದಿಲ್ಲ. + + ನಿಮ್ಮ ಡೇಟಾವನ್ನು ಬ್ಯಾಕಪ್ ಮಾಡಿ + ಡೇಟಾವನ್ನು ಆಮದು ಮಾಡಿ + ಅಪ್ಲಿಕೇಶನ್ ಸೆಟ್ಟಿಂಗ್‌ಗಳು + ಅಪ್ಲಿಕೇಶನ್ ಲಾಕ್ ಮಾಡಿ + ಅಧಿಸೂಚನೆಗಳನ್ನು ತೋರಿಸಿ + ಮೊತ್ತವನ್ನು ಮರೆಮಾಡಿ + 5 ಸೆಕೆಂಡುಗಳವರೆಗೆ ಮೊತ್ತವನ್ನು ತೋರಿಸಲು ಗುಪ್ತ ಮೊತ್ತವನ್ನು ಮರೆಮಾಡಿ ಕ್ಲಿಕ್ ಮಾಡಿ + ಇತರೆ + ಗೂಗಲ್ ಪ್ಲೇ ನಲ್ಲಿ ನಮಗೆ ರೇಟ್ ಮಾಡಿ + Ivy ವಾಲೆಟ್ ಅನ್ನು ಶೇರ್ ಮಾಡಿ + ಉತ್ಪನ್ನ + ಅಪಾಯದ ವಲಯ + ಎಲ್ಲಾ ಬಳಕೆದಾರರ ಡೇಟಾವನ್ನು ಅಳಿಸಿ + ಎಲ್ಲಾ ಬಳಕೆದಾರರ ಡೇಟಾವನ್ನು ಅಳಿಸುವುದೇ? + ಎಚ್ಚರಿಕೆ! ಈ ಕ್ರಿಯೆಯು %1$s ಗಾಗಿ ಎಲ್ಲಾ ಡೇಟಾವನ್ನು ಶಾಶ್ವತವಾಗಿ ಅಳಿಸುತ್ತದೆ ಮತ್ತು ಅದನ್ನು ಮರುಪಡೆಯಲು ನಿಮಗೆ ಸಾಧ್ಯವಾಗುವುದಿಲ್ಲ. + ನಿಮ್ಮ ಖಾತೆ + \'%1$s\' ಗಾಗಿ ಶಾಶ್ವತ ಅಳಿಸುವಿಕೆಯನ್ನು ಖಚಿತಪಡಿಸಿ + ನಿಮ್ಮ ಎಲ್ಲಾ ಡೇಟಾ + ಅಂತಿಮ ಎಚ್ಚರಿಕೆ! "ಅಳಿಸು" ಕ್ಲಿಕ್ ಮಾಡಿದ ನಂತರ ನಿಮ್ಮ ಡೇಟಾ ಶಾಶ್ವತವಾಗಿ ಕಣ್ಮರೆಯಾಗುತ್ತದೆ. + ಡೇಟಾವನ್ನು ರಫ್ತು ಮಾಡಲಾಗುತ್ತಿದೆ + ದಯವಿಟ್ಟು ನಿರೀಕ್ಷಿಸಿ, ಡೇಟಾವನ್ನು ರಫ್ತು ಮಾಡಲಾಗುತ್ತಿದೆ + ತಿಂಗಳ ಪ್ರಾರಂಭ ದಿನಾಂಕ + Ivy ಟೆಲಿಗ್ರಾಮ್ + ಸಹಾಯ ಕೇಂದ್ರ + ಮಾರ್ಗ ನಕ್ಷೆ + ವೈಶಿಷ್ಟ್ಯಕ್ಕಾಗಿ ವಿನಂತಿಸಿ + ಬೆಂಬಲಕ್ಕಾಗಿ ಸಂಪರ್ಕಿಸಿ + ಯೋಜನೆಯ ಕೊಡುಗೆದಾರರು + ಖಾತೆ + ಲಾಗ್ಔಟ್ + ಲಾಗಿನ್ + ಸಿಂಕ್ ಮಾಡಲಾಗುತ್ತಿದೆ… + ಡೇಟಾವನ್ನು ಕ್ಲೌಡ್‌ಗೆ ಸಿಂಕ್ ಮಾಡಲಾಗಿದೆ + ಸಿಂಕ್ ಮಾಡಲು ಟ್ಯಾಪ್ ಮಾಡಿ + ಸಿಂಕ್ ವಿಫಲವಾಗಿದೆ. ಸಿಂಕ್ ಮಾಡಲು ಟ್ಯಾಪ್ ಮಾಡಿ + ಅನಾಮಧೇಯ + CSV ಗೆ ರಫ್ತು ಮಾಡಿ + ಖರ್ಚು ಮಾಡಲು ಉಳಿದಿರುವ ಮೊತ್ತ + ಬಜೆಟ್ ಈ ಮೊತ್ತವನ್ನು ಮೀರಿದೆ + ಬಫರ್ ಈ ಮೊತ್ತವನ್ನು ಮೀರಿದೆ + ವಹಿವಾಟಿನ ಪ್ರಕಾರವನ್ನು ಹೊಂದಿಸಿ + ವರ್ಗಾವಣೆ + ಆಯ್ಕೆ ಮಾಡಲಾಗಿದೆ + ಹುಡುಕಿ (USD, EUR, GBP, BTC, ಇತ್ಯಾದಿ) + ಮೊದಲೇ ಆಯ್ಕೆ ಮಾಡಲಾಗಿದೆ + ಕ್ರಿಪ್ಟೋ + ವಿನಿಮಯ ದರ + ಬಣ್ಣವನ್ನು ಆರಿಸಿ + ಮರುಕ್ರಮಗೊಳಿಸಿ + ಕೀವರ್ಡ್ + ಬಜೆಟ್ ಅನ್ನು ಎಡಿಟ್ ಮಾಡಿ + ಬಜೆಟ್ ಅನ್ನು ರಚಿಸಿ + ಬಜೆಟ್ ಹೆಸರು + ಬಜೆಟ್ ಮೊತ್ತ + ನೀವು "%1$s" ಬಜೆಟ್ ಅನ್ನು ಅಳಿಸಲು ಖಚಿತವಾಗಿ ಬಯಸುವಿರಾ? + ಉಳಿತಾಯ ಗುರಿಯನ್ನು ಎಡಿಟ್ ಮಾಡಿ + ಐಕಾನ್ ಅನ್ನು ಆರಿಸಿ + ತಿಂಗಳನ್ನು ಆರಿಸಿ + ಅಥವಾ ಕಸ್ಟಮ್ ಶ್ರೇಣಿ + ದಿನಾಂಕವನ್ನು ಸೇರಿಸಿ + ಅಥವಾ ಕೊನೆಯದಾಗಿ + ಅಥವಾ ಸಾರ್ವಕಾಲಿಕ + ಸಾರ್ವಕಾಲಿಕ ಆಯ್ಕೆಯನ್ನು ತೆಗೆದುಹಾಕಿ + ಸಾರ್ವಕಾಲಿಕ ಆಯ್ಕೆಮಾಡಿ + ತಿಂಗಳ ಪ್ರಾರಂಭ ದಿನಾಂಕವನ್ನು ಆಯ್ಕೆಮಾಡಿ + ಕ್ರಿಪ್ಟೋವನ್ನು ಬೆಂಬಲಿಸುತ್ತದೆ + ಅಳಿಸಿ + ಉಳಿಸಿ + ಸೇರಿಸಿ + ರಚಿಸಿ + ಸಾಲವನ್ನು ಎಡಿಟ್ ಮಾಡಿ + ಹೊಸ ಸಾಲ + ಸಾಲದ ಹೆಸರು + ಸಂಬಂಧಿತ ಖಾತೆ + ಮುಖ್ಯ ವಹಿವಾಟು ರಚಿಸಿ + ಸಾಲದ ಮೊತ್ತವನ್ನು ನಮೂದಿಸಿ + "ಗಮನಿಸಿ: ನೀವು ವಿವಿಧ ಕರೆನ್ಸಿಯ ಖಾತೆಯೊಂದಿಗೆ ಸಾಲದೊಂದಿಗೆ ಸಂಯೋಜಿತವಾಗಿರುವ ಖಾತೆಯನ್ನು ಬದಲಾಯಿಸಲು ಪ್ರಯತ್ನಿಸುತ್ತಿರುವಿರಿ,\nಇಂದಿನ ವಿನಿಮಯ ದರದ ಆಧಾರದ ಮೇಲೆ ಎಲ್ಲಾ ಸಾಲದ ದಾಖಲೆಗಳನ್ನು ಮರು ಲೆಕ್ಕಾಚಾರ ಮಾಡಲಾಗುತ್ತದೆ" + ಸಾಲದ ವಿಧ + ಹಣವನ್ನು ಎರವಲು ಪಡೆಯಿರಿ + ಹಣವನ್ನು ಸಾಲವಾಗಿ ನೀಡಿ + ದಾಖಲೆ ಎಡಿಟ್ ಮಾಡಿ + ಹೊಸ ದಾಖಲೆ + ಸೂಚನೆ + ಬಡ್ಡಿ ಎಂದು ಗುರುತಿಸಿ + ಇಂದಿನ ಕರೆನ್ಸಿ ವಿನಿಮಯ ದರಗಳೊಂದಿಗೆ ಮೊತ್ತವನ್ನು ಮರು ಲೆಕ್ಕಾಚಾರ ಮಾಡಿ + ದಾಖಲೆ ಮೊತ್ತವನ್ನು ನಮೂದಿಸಿ + "%1$s" ದಾಖಲೆಯನ್ನು ಅಳಿಸಲು ನೀವು ಖಚಿತವಾಗಿ ಬಯಸುವಿರಾ? + "ಗಮನಿಸಿ: ನೀವು ವಿವಿಧ ಕರೆನ್ಸಿಯ ಖಾತೆಯೊಂದಿಗೆ ಸಾಲದ ದಾಖಲೆಯೊಂದಿಗೆ ಸಂಬಂಧಿಸಿದ ಖಾತೆಯನ್ನು ಬದಲಾಯಿಸಲು ಪ್ರಯತ್ನಿಸುತ್ತಿರುವಿರಿ.\nಇಂದಿನ ವಿನಿಮಯ ದರಗಳ ಆಧಾರದ ಮೇಲೆ ಮೊತ್ತವನ್ನು ಮರು ಲೆಕ್ಕಾಚಾರ ಮಾಡಲಾಗುತ್ತದೆ" + ಹೆಸರು ಎಡಿಟ್ ಮಾಡಿ + ಗಾಗಿ ಯೋಜನೆ + ಒಂದು ಬಾರಿ + ಹಲವಾರು ಬಾರಿ + ರಂದು ಆರಂಭವಾಗುತ್ತದೆ + ಪ್ರತಿ ಪುನರಾವರ್ತನೆ + ಸಲ್ಲಿಸಿ + ನಿಮಗೆ ಏನು ಬೇಕು? + ಒಂದು ವಾಕ್ಯದಲ್ಲಿ ಅದನ್ನು ವಿವರಿಸಿ. (ಮಾರ್ಕ್‌ಡೌನ್ ಅನ್ನು ಬೆಂಬಲಿಸುತ್ತದೆ) + ಕಳೆದ 12 ತಿಂಗಳುಗಳು + ಕಳೆದ 6 ತಿಂಗಳುಗಳು + ಕಳೆದ 4 ವಾರಗಳು + ಕಳೆದ 7 ದಿನಗಳು + ಇಂದು, %1$s + ನಿನ್ನೆ, %1$s + ನಾಳೆ, %1$s + ಅವಧಿ ಮೀರಿದೆ + ದೃಢೀಕರಣ ಯಶಸ್ವಿಯಾಗಿದೆ! + ಪ್ರಮಾಣೀಕರಣ ವಿಫಲವಾಗಿದೆ. + ನೀವು ಇಂದು ಯಾವುದಾದರೂ ವಹಿವಾಟು ನಡೆಸಿದ್ದೀರಾ? 🏁 + ಇಂದು ನಿಮ್ಮ ಖರ್ಚುಗಳನ್ನು ನೀವು ಟ್ರ್ಯಾಕ್ ಮಾಡಿದ್ದೀರಾ? 💸 + ನೀವು ಇಂದು ನಿಮ್ಮ ವಹಿವಾಟುಗಳನ್ನು ದಾಖಲಿಸಿದ್ದೀರಾ? 🏁 + ನಗದು + ಬ್ಯಾಂಕ್ + Revolut + + + ಸಾರಿಗೆ + ದಿನಸಿ + ಮನೋರಂಜನೆ + ಶಾಪಿಂಗ್ + ಉಡುಗೊರೆಗಳು + ಆರೋಗ್ಯ + ಹೂಡಿಕೆಗಳು + ಕಾರ್ + ಕೆಲಸ + ರೆಸ್ಟೋರೆಂಟ್ + ಸಂಸಾರ + ಸಾಮಾಜಿಕ ಜೀವನ + ಆಹಾರವನ್ನು ಆರ್ಡರ್ ಮಾಡಿ + ಪ್ರಯಾಣ + ಫಿಟ್ನೆಸ್ + ಸ್ವಯಂ ಅಭಿವೃದ್ಧಿ + ಉಡುಪುಗಳು + ಬ್ಯೂಟಿ + ವಿದ್ಯಾಭ್ಯಾಸ + ಸಾಕುಪ್ರಾಣಿ + ಕ್ರೀಡೆಗಳು + ನಿಮ್ಮ ಆರಂಭಿಕ ಮೊತ್ತವನ್ನು ಹೊಂದಿಸಿ + ಖಾತೆಗಳಿಗೆ + ಖಾತೆಯನ್ನು ಟ್ಯಾಪ್ ಮಾಡಿ -> ಅದರ ಮೊತ್ತವನ್ನು ಟ್ಯಾಪ್ ಮಾಡಿ -> ಪ್ರಸ್ತುತ ಮೊತ್ತವನ್ನು ನಮೂದಿಸಿ. ಅಷ್ಟೇ!]]> + ನಿಮ್ಮ ಮೊದಲ ಯೋಜಿತ ಪಾವತಿಯನ್ನು ರಚಿಸಿ + ನಿಮ್ಮ ಚಂದಾದಾರಿಕೆಗಳು, ಬಾಡಿಗೆ, ಸಂಬಳ, ಇತ್ಯಾದಿಗಳಂತಹ ಮರುಕಳಿಸುವ ವಹಿವಾಟುಗಳ ಟ್ರ್ಯಾಕಿಂಗ್ ಅನ್ನು ಸ್ವಯಂಚಾಲಿತಗೊಳಿಸಿ. ನೀವು ಎಷ್ಟು ಹಣವನ್ನು ಪಾವತಿಸಬೇಕು/ಮುಂಗಡವಾಗಿ ಪಡೆಯಬೇಕು ಎಂಬುದನ್ನು ತಿಳಿದುಕೊಳ್ಳುವ ಮೂಲಕ ನಿಮ್ಮ ಹಣಕಾಸಿನ ವಿಷಯದಲ್ಲಿ ಮುಂದೆ ಇರಿ. + ನಿಮಗೆ ಗೊತ್ತೇ? + Ivy ವಾಲೆಟ್ ವಿಜೆಟ್ ಅನ್ನು ಹೊಂದಿದ್ದು ಅದು ನಿಮ್ಮ ಮುಖಪುಟ ಪರದೆಯಿಂದ 1-ಕ್ಲಿಕ್‌ನೊಂದಿಗೆ ಆದಾಯ/ವೆಚ್ಚಗಳು/ವರ್ಗಾವಣೆ ವಹಿವಾಟುಗಳನ್ನು ಸೇರಿಸಲು ನಿಮಗೆ ಅನುಮತಿಸುತ್ತದೆ.\n\nಗಮನಿಸಿ: "ವಿಜೆಟ್ ಸೇರಿಸಿ" ಬಟನ್ ಕಾರ್ಯನಿರ್ವಹಿಸದಿದ್ದರೆ, ದಯವಿಟ್ಟು ಅದನ್ನು ನಿಮ್ಮ ಲಾಂಚರ್‌ನ ವಿಜೆಟ್‌ಗಳ ಮೆನುವಿನಿಂದ ಹಸ್ತಚಾಲಿತವಾಗಿ ಸೇರಿಸಿ. + ವಿಜೆಟ್ ಸೇರಿಸಿ + ಬಜೆಟ್ ಅನ್ನು ಹೊಂದಿಸಿ + Ivy ವಾಲೆಟ್ ನಿಮ್ಮ ಖರ್ಚುಗಳನ್ನು ನಿಷ್ಕ್ರಿಯವಾಗಿ ಟ್ರ್ಯಾಕ್ ಮಾಡಲು ಸಹಾಯ ಮಾಡುತ್ತದೆ ಆದರೆ ಬಜೆಟ್‌ಗಳನ್ನು ಹೊಂದಿಸುವ ಮೂಲಕ ಮತ್ತು ಅವುಗಳಿಗೆ ಅಂಟಿಕೊಳ್ಳುವ ಮೂಲಕ ನಿಮ್ಮ ಹಣಕಾಸಿನ ಭವಿಷ್ಯವನ್ನು ಪೂರ್ವಭಾವಿಯಾಗಿ ಸೃಷ್ಟಿಸುತ್ತದೆ. + ವರ್ಗಗಳ ಪ್ರಕಾರ ನಿಮ್ಮ ಖರ್ಚುಗಳ ರಚನೆಯನ್ನು ನೀವು ನೋಡಬಹುದು! ಇದನ್ನು ಪ್ರಯತ್ನಿಸಿ, ನಿಮ್ಮ ಮೊತ್ತದ ಕೆಳಗಿನ ಬೂದು/ಕಪ್ಪು ವೆಚ್ಚಗಳ ಬಟನ್ ಅನ್ನು ಟ್ಯಾಪ್ ಮಾಡಿ. + ಖರ್ಚುಗಳ ಪೈಚಾರ್ಟ್ + Ivy ವಾಲೆಟ್ ಅನ್ನು ವಿಮರ್ಶಿಸಿ + ನಿಮ್ಮ ಪ್ರತಿಕ್ರಿಯೆಯನ್ನು ನಮಗೆ ನೀಡಿ! ನಮಗೆ ವಿಮರ್ಶೆಯನ್ನು ಬರೆಯುವ ಮೂಲಕ Ivy ವಾಲೆಟ್ ಉತ್ತಮವಾಗಲು ಮತ್ತು ಬೆಳೆಯಲು ಸಹಾಯ ಮಾಡಿ. ಅಭಿನಂದನೆಗಳು, ಆಲೋಚನೆಗಳು ಮತ್ತು ವಿಮರ್ಶಕರು ಎಲ್ಲರಿಗೂ ಸ್ವಾಗತ! ನಾವು ನಮ್ಮ ಕೈಲಾದಷ್ಟು ಮಾಡುತ್ತೇವೆ.\n\nಚೀರ್ಸ್,\nIvy ತಂಡ + ನಮಗೆ ಬೆಳೆಯಲು ಸಹಾಯ ಮಾಡಿ ಇದರಿಂದ ನಾವು ಅಭಿವೃದ್ಧಿಯಲ್ಲಿ ಹೆಚ್ಚು ಹೂಡಿಕೆ ಮಾಡಬಹುದು ಮತ್ತು ನಿಮಗಾಗಿ ಅಪ್ಲಿಕೇಶನ್ ಅನ್ನು ಉತ್ತಮಗೊಳಿಸಬಹುದು. Ivy ವಾಲೆಟ್ ಅನ್ನು ಹಂಚಿಕೊಳ್ಳುವ ಮೂಲಕ ನೀವು ಇಬ್ಬರು ಡೆವಲಪರ್‌ಗಳನ್ನು ಸಂತೋಷಪಡಿಸುತ್ತೀರಿ ಮತ್ತು ಅವರ ಹಣಕಾಸಿನ ಮೇಲೆ ಹಿಡಿತ ಸಾಧಿಸಲು ಸ್ನೇಹಿತರಿಗೆ ಸಹಾಯ ಮಾಡುತ್ತೀರಿ. + ಸ್ನೇಹಿತರೊಂದಿಗೆ ಹಂಚಿಕೊಳ್ಳಿ + ನಿಮ್ಮ ಆದಾಯ ಮತ್ತು ಖರ್ಚಿನ ಬಗ್ಗೆ ಆಳವಾದ ಒಳನೋಟಗಳನ್ನು ಪಡೆಯಲು ನೀವು ವರದಿಗಳನ್ನು ರಚಿಸಬಹುದು. ನಿಮ್ಮ ಹಣಕಾಸಿನ ಮೇಲೆ ಉತ್ತಮ ವೀಕ್ಷಣೆಯನ್ನು ಪಡೆಯಲು ಪ್ರಕಾರ, ಸಮಯದ ಅವಧಿ, ವರ್ಗ, ಖಾತೆಗಳು, ಮೊತ್ತ, ಕೀವರ್ಡ್‌ಗಳು ಮತ್ತು ಹೆಚ್ಚಿನವುಗಳ ಮೂಲಕ ನಿಮ್ಮ ವಹಿವಾಟುಗಳನ್ನು ಫಿಲ್ಟರ್ ಮಾಡಿ. + ಒಂದು ವರದಿಯನ್ನು ಮಾಡಿ + Ivy ವಾಲೆಟ್ ಅನ್ನು ಉತ್ತಮಗೊಳಿಸಲು ಬಯಸುವಿರಾ? ನಮಗೆ ವಿಮರ್ಶೆಯನ್ನು ಬರೆಯಿರಿ. ನಿಮಗೆ ಬೇಕಾದುದನ್ನು ಮತ್ತು ಅಗತ್ಯವಿರುವದನ್ನು ಅಭಿವೃದ್ಧಿಪಡಿಸಲು ನಮಗೆ ಇರುವ ಏಕೈಕ ಮಾರ್ಗವಾಗಿದೆ. ಅಲ್ಲದೆ ಇದು ಪ್ಲೇಸ್ಟೋರ್ ನಲ್ಲಿ ಉನ್ನತ ಶ್ರೇಣಿಯನ್ನು ಪಡೆಯಲು ನಮಗೆ ಸಹಾಯ ಮಾಡುತ್ತದೆ ಆದ್ದರಿಂದ ನಾವು ಮಾರ್ಕೆಟಿಂಗ್‌ಗಿಂತ ಉತ್ಪನ್ನದ ಮೇಲೆ ಹಣವನ್ನು ಖರ್ಚು ಮಾಡಬಹುದು.\n\nನಾವು ನಮ್ಮ ಕೈಲಾದಷ್ಟು ಮಾಡುತ್ತೇವೆ.\nIvy ತಂಡ + ನಮಗೆ ನಿಮ್ಮ ಸಹಾಯ ಬೇಕು! + ನಾವು ಕೇವಲ ಡಿಸೈನರ್ ಮತ್ತು ನಮ್ಮ 9–5 ಉದ್ಯೋಗಗಳ ನಂತರ ಅಪ್ಲಿಕೇಶನ್‌ನಲ್ಲಿ ಕೆಲಸ ಮಾಡುವ ಡೆವಲಪರ್ ಆಗಿದ್ದೇವೆ. ಪ್ರಸ್ತುತ, ನಾವು ನಷ್ಟ ಮತ್ತು ಬಳಲಿಕೆಯನ್ನು ಸೃಷ್ಟಿಸಲು ಸಾಕಷ್ಟು ಸಮಯ ಮತ್ತು ಹಣವನ್ನು ಹೂಡಿಕೆ ಮಾಡುತ್ತೇವೆ. ನಾವು Ivy ವಾಲೆಟ್ ಅನ್ನು ಅಭಿವೃದ್ಧಿಪಡಿಸುವುದನ್ನು ಮುಂದುವರಿಸಬೇಕೆಂದು ನೀವು ಬಯಸಿದರೆ ದಯವಿಟ್ಟು ಅದನ್ನು ಸ್ನೇಹಿತರು ಮತ್ತು ಕುಟುಂಬದೊಂದಿಗೆ ಹಂಚಿಕೊಳ್ಳಿ.\n\nP.S. ಗೂಗಲ್ ಪ್ಲೇಸ್ಟೋರ್ ವಿಮರ್ಶೆಗಳು ಸಹ ಬಹಳಷ್ಟು ಸಹಾಯ ಮಾಡುತ್ತವೆ! + Ivy ವ್ಯಾಲೆಟ್ ಮುಕ್ತ ಸಂಪನ್ಮೂಲ ಆಗಿದೆ! + Ivy ವ್ಯಾಲೆಟ್ ನ ಕೋಡ್ ತೆರೆದಿದೆ ಮತ್ತು ಪ್ರತಿಯೊಬ್ಬರೂ ಅದನ್ನು ನೋಡಬಹುದು. ಪ್ರತಿಯೊಂದು ಸಾಫ್ಟ್‌ವೇರ್ ಉತ್ಪನ್ನಕ್ಕೂ ಪಾರದರ್ಶಕತೆ ಮತ್ತು ನೈತಿಕತೆ ಅತ್ಯಗತ್ಯ ಎಂದು ನಾವು ನಂಬುತ್ತೇವೆ. ನೀವು ನಮ್ಮ ಕೆಲಸವನ್ನು ಇಷ್ಟಪಟ್ಟರೆ ಮತ್ತು ಅಪ್ಲಿಕೇಶನ್ ಅನ್ನು ಉತ್ತಮಗೊಳಿಸಲು ಬಯಸಿದರೆ ನೀವು ನಮ್ಮ ಸಾರ್ವಜನಿಕ ಗಿಟ್ ಹಬ್ ರೆಪೊಸಿಟರಿಯಲ್ಲಿ ಕೊಡುಗೆ ನೀಡಬಹುದು. + ಕೊಡುಗೆ ನೀಡಿ + ಮೊತ್ತವನ್ನು ಹೊಂದಿಸಿ + ದೃಢೀಕರಣದ ಅಗತ್ಯವಿದೆ + ಅಪ್ಲಿಕೇಶನ್ ಅನ್ನು ಅನ್‌ಲಾಕ್ ಮಾಡಲು ನೀವು ಈ ಸಾಧನಕ್ಕೆ ಪ್ರವೇಶವನ್ನು ಹೊಂದಿರುವಿರಿ ಎಂಬುದನ್ನು ಸಾಬೀತುಪಡಿಸಿ. + ಒಟ್ಟು ಬಜೆಟ್ + ವರ್ಗದ ಬಜೆಟ್ + ಬಹು-ವರ್ಗ (%1$s) ಬಜೆಟ್ + ಎರವಲು ಪಡೆಯಲಾಗಿದೆ + ಸಾಲ ನೀಡಲಾಗಿದೆ + ಜನವರಿ + ಫೆಬ್ರವರಿ + ಮಾರ್ಚ್ + ಏಪ್ರಿಲ್ + ಮೇ + ಜೂನ್ + ಜುಲೈ + ಆಗಸ್ಟ್ + ಸೆಪ್ಟೆಂಬರ್ + ಅಕ್ಟೋಬರ್ + ನವೆಂಬರ್ + ಡಿಸೆಂಬರ್ + ದಿನಗಳು + ದಿನ + ವಾರಗಳು + ವಾರ + ತಿಂಗಳುಗಳು + ತಿಂಗಳು + ವರ್ಷಗಳು + ವರ್ಷ + + ಖಾತೆಗಳ ಪರದೆಯಲ್ಲಿ ಖಾತೆ ವರ್ಗಾವಣೆಯನ್ನು ಆದಾಯ ಅಥವಾ ಖರ್ಚು ಎಂದು ಪರಿಗಣಿಸುತ್ತದೆ + ಹೋಮ್ + ವರದಿಯನ್ನು ರಚಿಸಲಾಗುತ್ತಿದೆ… + ಹೀಗೆ ವಿಂಗಡಿಸಿ + ಎಲ್ಲವನ್ನೂ ಬಿಟ್ಟುಬಿಡಿ + ಎಲ್ಲವನ್ನೂ ಬಿಟ್ಟುಬಿಡಿ ಎಂಬುದನ್ನು ದೃಢೀಕರಿಸಿ + ಎಲ್ಲಾ ಮಿತಿಮೀರಿದ ಯೋಜಿತ ಪಾವತಿಗಳನ್ನು ಬಿಟ್ಟುಬಿಡಲು ನೀವು ಖಚಿತವಾಗಿ ಬಯಸುವಿರಾ? + ಆಫ್‌ಲೈನ್ ಮೋಡ್‌ಗೆ ಬದಲಿಸಿ + ಎಚ್ಚರಿಕೆ! ಈ ಕ್ರಿಯೆಯು ನಿಮ್ಮ ಎಲ್ಲಾ ಕ್ಲೌಡ್-ಸಂಗ್ರಹಿಸಿದ ಡೇಟಾವನ್ನು %1$s ಗಾಗಿ ಶಾಶ್ವತವಾಗಿ ಅಳಿಸುತ್ತದೆ, ನಿಮ್ಮ ಸ್ಥಳೀಯ ಅಪ್ಲಿಕೇಶನ್‌ನಲ್ಲಿ ಸಂಗ್ರಹಿಸಲಾದ ಆಫ್‌ಲೈನ್ ಡೇಟಾ ಉಳಿಯುತ್ತದೆ. + ಎಲ್ಲಾ ಕ್ಲೌಡ್-ಸಂಗ್ರಹಿಸಿದ ಡೇಟಾವನ್ನು ಅಳಿಸುವುದೇ? + ಪ್ರಯೋಗಾತ್ಮಕ + ಪ್ರಯೋಗಾತ್ಮಕ ಸೆಟ್ಟಿಂಗ್‌ಗಳು + Wallet balance + diff --git a/resources/src/main/res/values-pl/strings.xml b/resources/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000..68ebdf1 --- /dev/null +++ b/resources/src/main/res/values-pl/strings.xml @@ -0,0 +1,452 @@ + + + Konta + Total: %1$s %2$s + WPŁYWY W TYM MIESIĄCU + WYDATKI W TYM MIESIĄCU + (wykluczony) + WPŁYWY + WYDATKI + APLIKACJA ZABLOKOWANA + Autoryzacja w celu otwarcia aplikacji + Odblokuj + AKTUALNE SALDO + SALDO PO ZAPLANOWANYCH WYDATKACH + Połącz + Synchronizuj transakcje + Synchronizuję transakcje… + Synchronizacja z bankiem włączona: + Usuń odbiorcę + Dodaj budżet + Brak budżetu + Nie masz ustawionych żadnych budżetów.\nDotknij "+ Dodaj budżet" aby go dodać. + Budżety + %1$s %2$s dla kategorii + %1$s %2$s aplikacja do budżetu + Informacja o budżetach: %1$s / %2$s + Informacja o budżecie: %1$s%2$s + Dodaj kategorię + Wydatki + Ilość wydatków + Wpływy + Ilość wpływów + Wykres salda + SALDO %1$s + Wykresy + Okres: + Kategorie + Wyeksportuj plik CSV + Wyeksportuj plik CSV ze standardowymi opcjami + Proszę o użycie standardowych opcji i upewnij się, że zaznaczono nagłówki. + Jak zaimportować + otwórz + Kroki + Jak zrobić + Wideo + Artykuł + Załaduj plik CSV + Wyeksportuj dane + Załaduj plik CSV/ZIP + Wyeksportuj do pliku + Zestaw znaków: UTF-8\nSeparator liczb dziesiętnych: Kropka dziesiętna \'.\'\nZnak ogranicznika: Przecinek \',\' + Wyeksportuj do pliku Excel + Konwersja pliku XSL do CSV + !UWAGA: Jeśli plik nie posiada rozszerzenia ".xls", dodaj je manualnie poprzez zmianę nazwy pliku. + Konwerter plików CSV Online + Sprawdź foldery "Oferty" i "Spam" w skrzynce pocztowej + Pobierz \"transactions_export…\" plik załączony w mailu. + Jeśli masz więcej niż jedną walutę musisz pobrać każdy \"transactions_export…\" plik i zaimportować go do Ivy. + Zaimpotruj z + Proszę czekać + Importowanie pliku CSV + Sukces + Błąd + Zaimportowano + %1$d transakcji + %1$d kont + %1$d kategorii + Błąd + %1$d kolumn z pliku CSV nie zostały odczytane poprawnie + Wykonano + Dodaj opis + Opis + Zaplanowane + Dodaj pieniądze do + Zapłać + Z + Konto + Do + Dodaj konto + Tytuł wpływu + Tytuł wydatku + Tytuł transferu + Wydatek + Dodaj planowaną datę płatności + Zapłać + Otrzymaj + Potwierdź usunięcie + Usunięcie tej transakcji spowoduje usunięcie jej z historii transakcji i odpowiednią aktualizację salda. + Potwierdź zmianę konta + Uwaga: Próbujesz zmienić konto powiązane z pożyczką na konto w innej walucie, \nWszystkie rekordy pożyczek zostaną przeliczone na podstawie dzisiejszych kursów wymiany + Potwierdź + Proszę czekać, przeliczanie wszystkich rekordów pożyczek + Utworzony + Cześć + Cześć %1$s + Obroty: %1$s%2$s %3$s + Szukaj transakcji + Ivy Wallet jest open-source + Cel oszczędzania + Szybki dostęp + Ustawienia + Tryb jasny + Tryb ciemny + Tryb automatyczny + Zaplanowane\nPłatności + Udostępnij Ivy + Zgłoszenia + Pożyczki + Ustaw walutę + Brak transakcji + Nie masz żadnych transakcji w wyznaczonym okresie czasu. + Nie masz żadnych transakcji w %1$s.\nMożesz je dodać przyciskiem \"+\". + Dodaj pożyczkę + Brak pożyczek + Nie masz żadnych pożyczek.\nDotknij przycisk \"+ Dodaj pożyczkę\" Aby ją dodać. + Uwaga: Usunięcie tej pożyczki spowoduje jej trwałe usunięcie i usunięcie wszystkich powiązanych z nią rekordów pożyczek. + Proszę czekać, przeliczanie wszystkich rekordów pożyczek + Zapłac + %1$s %2$s pozostało + Oprocentowanie kredytu + %1$s %2$s zapłacono + Dodaj rekord + Oprocentowanie + Brak rekordów + Nie masz żadnych danych dotyczących tej pożyczki. Dotnik "Dodaj rekord", aby go utworzyć. + Dodaj wpływy + Dodaj wydatki + Nieokreślony + %1$s\%% + Transfery konta + Nie masz żadnych transakcji %1$s.\nMożesz dodać transakcję, przewijając w dół i dotykając przycisku "Dodaj wpływ" lub "Dodaj wydatek" u góry. + Uwaga: Usunięcie tego konta spowoduje jego trwałe usunięcie i usunięcie wszystkich powiązanych z nim transakcji. + Uwaga: Usunięcie tej kategorii spowoduje jej trwałe usunięcie. + Edytuj + transakcje + Pulpit + Dodaj zaplanowaną płatność + DODAJ WPŁYW + DODAJ WYDATEK + TRANSFER KONTA + Pomiń + Dodaj nowy + Od %1$s + Do %1$s + Zakres + Prytatność i \nzbieranie danych + Przeciągnij, aby zaakceptować nasze zasady i warunki + Zaakceptowano nasze zasady i warunki + Przeciągnij, aby zaakceptować naszą politykę prywatności + Zaakceptowano naszą politykę prywatności + Zasady i warunki + Polityka prywatności + Śledź swoje wpływy, wydatki i budżet z Ivy.\n\nIntuicyjne UI, cykliczne i planowane płatności, zarządzanie wieloma kontami, organizowanie transakcji w kategoriach, czytelne statystyki, eksport do CSV i wiele więcej. + Podaj swoje imię\naby spersonalizować swój\nportfel + Jak masz na imię? + Enter + Dodaj konta + Sugestie + Dalej + Dodaj kategorię + Sugestie + Ustaw + Twoja personalna aplikacja do zarządzania finansami + #opensource + Błąd. Spróbuj ponownie: %1$s + Logowanie… + Sukces! + Zaloguj się przez Google + Konto offline + SYNCHRONIZUJ SWOJE DANE PRZEZ IVY CLOUD + Integralność danych i bezpieczeństwo nie jest gwarantowane! + LUB ZAŁÓŻ KONTO OFFLINE + Twoje dane będą zapisywane lokalnie (tylko na Twoim telefonie) i nie będą synchronizowane z chmurą. Ryzykujesz utratą danych, jeśli odinstalujesz aplikację lub zmienisz urządzenie. Zawsze możesz aktywować synchronizację później, jeśli się zdecydujesz. + + Logując się akceptujesz naszą %1$s i %2$s. + Importuj plik CSV + z Ivy lub innej aplikacji + Importowanie pliku kopii zapasowej z innego może zająć do 5 minut. Zawsze możesz zaimportować swoje dane później, jeśli chcesz. + Zaimportuj plik kopii zapasowej + Zacznij na świeżo + Usunięcie tej planowanej płatności spowoduje usunięcie wszystkich powiązanych z nią nieopłaconych, nadchodzących lub zaległych transakcji. + Ustaw typ płatności + Zaplanowany start w + POWTARZA SIĘ CO %1$d %2$s + usunięto + "ZAPLANOWANE NA " + nieważne + "STARTUJE %1$s " + Dodaj płatność + Jednorazowa płatność + Płatności cykliczne + Brak zaplanowanych płatności` + Nie masz żadnych zaplanowanych płatności.\nDotknij przycisk \'⚡\' na dole, aby dodać. + Zaplanowane płatności + Dzisiaj + Wczoraj + Jutro + Termin na %1$s + Nadchodzące + Zaległość + wydatki + wpływy + Edytuj konto + Nowe konto + Nazwa konta + Dołącz konto + Podaj saldo konta + Wybierz walutę + Kalkulator + Obliczenie (+-/*=) + Edytuj kategorię + Utwórz kategorię + Nazwa kategorii + Wybierz kategorię + Wprowadź tutaj opis + Wyczyść filtr + Filtr + Zastosuj filtr + Na typy + Wpływy + Okres czasu + Zaznacz zakres czasu + Konta (%1$d) + Kategorie (%1$d) + Odznacz wszystko + Zaznacz wszystko + Ilość (opcjonalnie) + Słowa kluczowe (opcjonalnie) + ZAWIERA + Dodaj słowo kluczowe + WYKLUCZONE + Nie znaleziono żadnych transakcji dla tego filtra. + Brak filtra + Aby wygenerować raport, musisz najpierw ustawić prawidłowy filtr. + Ustaw filtr + Eksportuj + Nie masz żadnych transakcji do zapytania "%1$s". + + Kopia zapasowa danych + Import danych + Ustawienia aplikacji + Zablokuj aplikację + Pokazuj powiadomienia + Ukryj saldo + Kliknij na ukryte saldo, aby je podświetlić na 5 sekund + Inne + Oceń nas w Google Play + Udostępnij Ivy Wallet + Produkt + Strefa zagrożenia + Usuń wszystkie dane + Usunąć wszystkie dane? + OSTRZEŻENIE! Ta czynność spowoduje %1$s TRWAŁE usunięcie wszystkich danych i nie będzie można ich odzyskać! + Twoje konto + Potwierdź usunięcie wszystkich danych \'%1$s\' + wszystkich Twoich danych + OSTATNIE OSTRZEŻENIE! Po kliknięciu „Usuń” Twoje dane znikną bezpowrotnie. + Eksportuję dane + Proszę czekać, eksportuję dane... + Pierwszy dzień miesiąca + Ivy Telegram + Centrum pomocy + Roadmap + Poproś o dodanie nowych funkcji + Skontaktuj się z supportem + Współtwórcy projektu + KONTO + Wyloguj + Zaloguj + Synchronizuję… + Dane zsynchronizowane do chmury + Dotknij, aby zsynchronizować + Synchronizacja nieudana. Dotknij, aby schynchronizować + Anonim + Eksportuj do pliku CSV + Zostało do wydania + Budżet przekroczony o + Bufor przekroczony o + Ustaw typ transakcji + Transfer + Zaznaczone + Szukaj (USD, EUR, GBP, BTC, etc) + Wstępnie wybrane + Krypto + Kurs wymiany + Wybierz kolor + Zmień kolejność + Słowo kluczowe + Edytuj budżet + Utwórz budżet + Nazwa budżetu + KWOTA BUDŻETU + Jesteś pewny, że chcesz usunąć ten "%1$s" budżet? + Edytuj cel oszczędności + Wybierz ikonę + Wybierz miesiąc + lub własny zakres + Dodaj datę + lub w ostatnim + lub cały czas + Usuń zaznaczenie opcji Cały czas + Zaznacz opcję Cały czas + Cały czas + Wybierz pierwszy dzień miesiąca + wspiera krypto + Usuń + Zapisz + Dodaj + Utwórz + Edytuj pożyczkę + Nowa pożyczka + Nazwa pożyczki + Powiązane konto + Utwórz transakcję główną + WPROWADŹ WARTOŚĆ POŻYCZKI + "Uwaga: Próbujesz zmienić konto powiązane z pożyczką na konto w innej walucie, \nWszystkie rekordy pożyczek zostaną przeliczone na podstawie dzisiejszych kursów wymiany " + Typ pożyczki + Pożycz pieniądze + Oddaj pieniądze + Edytuj rekord + Nowy rekord + Notatka + Oznacz jako zainteresowane + Przelicz kwotę z dzisiejszymi kursami wymiany walut + WPROWADŹ KWOTĘ REKORDU + Jesteś pewny, że chcesz usunąć ten "%1$s" rekord? + "Uwaga: Próbujesz zmienić konto powiązane z pożyczką na konto w innej walucie, \nWszystkie rekordy pożyczek zostaną przeliczone na podstawie dzisiejszych kursów wymiany " + Edytuj nazwę + Zaplanuj na + Jednorazowo + Kilka razy + Zaczyna się + Powtarza się co + Złóz + Co potrzebujesz? + Wytłumacz to w jednym zdaniu. (wspiera obniżkę ceny) + Ostatnie 12 miesięcy + Ostatnie 6 miesięcy + Ostatnie 4 tygodnie + Ostatnie 7 dni + Dzisiaj, %1$s + Wczoraj, %1$s + Jutro, %1$s + Wygasłe + Uwierzytelnienie powiodło się! + Uwierzytelnienie nie powiodło się. + Dokonałeś dziś jakiejś transakcji? 🏁 + Śledziłeś dziś swoje wydatki? 💸 + Nie zapomij zapisywać swoich transakcji! 🏁 + Gotówka + Bank + Revolut + + + Transport + Produkty spożywcze + Rozrywka + Zakupy + Prezenty + Zdrowie + Inwestycje + Samochó + Praca + Restauracje + Rodzina + Życie społeczne + Zamawianie jedzenia + Podróże + Fitness + Samorozwój + Ubrania + Piękno + Edukacja + Zwierzę + Sporty + Dostosuj swoje początkowe saldo + Do kont + Dotnkij na konto -> Dotknij jego saldo -> Wprowadź aktualne saldo. To tyle!]]> + Utwórz swoją pierwszą zaplanowaną płatność + Zautomatyzuj śledzenie powtarzających się transakcji, takich jak abonamenty, czynsz, wynagrodzenie itp. Bądź o krok przed swoimi finansami, wiedząc, ile musisz zapłacić/otrzymać z góry. + Czy wiedziałeś? + Ivy Wallet ma fajny widżet, który pozwala dodawać transakcje DOCHODU/WYDATKU/PRZELEWU jednym kliknięciem z pulpitu\n\nUwaga: jeśli przycisk „Dodaj widżet” nie działa, dodaj go ręcznie z widżetów w aplikacji pulpitu. + Dodaj widżet + Ustaw budżet + Ivy Wallet nie tylko pomaga pasywnie śledzić wydatki, ale także proaktywnie kreować swoją przyszłość finansową poprzez ustalanie budżetów i trzymanie się ich. + Możesz zobaczyć strukturę wydatków według kategorii! Wypróbuj, dotknij szaro-czarnego przycisku Wydatki tuż pod saldem. + Wykres kołowy wydatków + Zrecenzuj Ivy Wallet + Podziel się swoją opinią! Pomóż Ivy Wallet stać się lepszym i rozwijać, pisząc nam recenzję. Komplementy, pomysły i krytyka są mile widziane! Dołożymy wszelkich starań.\n\nPozdrawiamy,\nIvy Team + Pomóż nam się rozwijać, abyśmy mogli inwestować więcej w rozwój i ulepszać aplikację dla Ciebie. Udostępniając Ivy Wallet, uszczęśliwisz dwóch programistów, a także pomożesz przyjacielowi przejąć kontrolę nad ich finansami. + Podziel się ze znajomymi + Możesz generować raporty, aby uzyskać szczegółowe informacje na temat swoich dochodów i wydatków. Filtruj transakcje według typu, okresu, kategorii, kont, kwoty, słów kluczowych i innych, aby uzyskać lepszy wgląd w swoje finanse. + Stwórz raport + Chcesz ulepszyć Ivy Wallet? Napisz nam recenzję. Tylko w ten sposób możemy rozwijać to, czego pragniesz i potrzebujesz. Pomaga nam to również zająć wyższą pozycję w Sklepie Play, dzięki czemu możemy wydawać pieniądze na produkt, a nie na marketing.\n\Dokładamy naszych starań.\nIvy Team + Potrzebujemy Twojej pomocy! + Jesteśmy tylko projektantami i programistami pracującymi nad aplikacją po godzinach pracy. Obecnie inwestujemy dużo czasu i pieniędzy, aby generować tylko straty i zmęczenie. Jeśli chcesz, żebyśmy się dalej rozwijali Ivy Wallet proszę podziel się nim z przyjaciółmi i rodziną.\n\nP.S. Recenzję w Sklepie Play! + Ivy Wallet jest open-source! + Ivy Wallet\'s kod jest otwarty i każdy może go zobaczyć. Wierzymy, że przejrzystość i etyka są koniecznością dla każdego oprogramowania. Jeśli podoba Ci się nasza praca i chcesz ulepszyć aplikację, możesz dołączyć się do naszego publicznego repozytorium Github. + Weź w tym udział + Dostosuj saldo + Uwierzytelnianie wymagane + Udowodnij, że masz dostęp do tego urządzenia, aby odblokować aplikację. + Cały budżet + Kategorie budżetu + Wielo kategoryczny (%1$s) Budżet + POŻYCZONE + ODDANE + Styczeń + Luty + Marzec + Kwiecień + Maj + Czerwiec + Lipiec + Sierpień + Wrzesień + Październik + Listopad + Grudzień + dni + dzień + tygodnie + tydzień + miesiące + miesiąc + lata + rok + + Traktuje przelewy na konta jako przychód lub wydatek na ekranie kont + Pulpit + Generowanie raportu... + Sortuj po + Pomiń wszystkie + Potwierdź pominięcie wszystkich + Czy na pewno chcesz pominąć wszystkie zaległe zaplanowane płatności? + Przełącz na konto offline + UWAGA! Ta czynność spowoduje TRWAŁE usunięcie wszystkich przechowywanych w chmurze danych %1$s, dane offline przechowywane w Twojej aplikacji lokalnej pozostaną nienaruszone. + Usunąć wszystkie dane z chmury? + Eksperymentalne + Ustawienia eksperymentalne + Saldo portfela + Oznacz jako podkategorię + Kategoria pierwotna + *Zostało zaznaczone jako pierwotna kategoria + Odpakuj wszystkie podkategorie + Zaznacz wszystkie \"Opcjonalne kolumny\" w opcjach eksportu + Zmień nazwę kategorii transferu + W przypadku użytkowników nieanglojęzycznych zmień nazwę kategorii systemu transferu na \"Transfer\" w opcjach eksportu + Zastrzeżenia + Podkategorie nie są wspierane + Kolumny Zdarzenie, Ludzie i Miejsce będą ignorowane + diff --git a/resources/src/main/res/values-pt/strings.xml b/resources/src/main/res/values-pt/strings.xml new file mode 100644 index 0000000..1d72792 --- /dev/null +++ b/resources/src/main/res/values-pt/strings.xml @@ -0,0 +1,452 @@ + + + Contas + Total: %1$s %2$s + ENTRADAS ESTE MÊS + GASTOS ESTE MÊS + (excluído) + ENTRADAS + GASTOS + APP BLOQUEADO + Autenticar para entrar no aplicativo + Desbloquear + SALDO ATUAL + SALDO APÓS PAGAMENTOS PLANEJADOS + Conectar + Sincronizar transações + Sincronizando transações… + Sincronização bancária habilitada: + Remover cliente + Adicionar orçamento + Sem orçamentos + Você não tem um orçamento definido.\nToque em "+ Adicionar orçamento" para adicionar um. + Orçamentos + %1$s %2$s por categorias + %1$s %2$s orçamento de aplicação + informações de orçamento: %1$s / %2$s + informações de orçamento: %1$s%2$s + Adicionar categoria + Gastos + Conta de gastos + Entradas + Conta de entradas + Gráfico de balanço + BALANÇO %1$s + Gráficos + Período: + Categorias + Exportar arquivo CSV + Exportar arquivo CSV com opções padrão + Utilizar as opções padrão e incluir cabeçalhos. + Como importar + aberto + Passos + Como + Vídeo + Artigo + Carregar arquivo CSV + Exportar dados + Carregar arquivo CSV/ZIP + Exportar para um arquivo + Conjunto de caracteres: UTF-8\nSeparador decimal: Ponto decimal \'.\'\nCaractere delimitador: Vírgula \',\' + Exportar arquivo Excel + Converter XLS para CSV + OBSERVAÇÃO!: Se o arquivo exportado não tiver a extensão ".xls", adicione-o renomeando o arquivo manualmente. + Conversor CSV online GRATUITO + Verifique as pastas "Promoções" e "Spam" de seus e-mails + Faça o download do arquivo \"transactions_export…\" anexado ao e-mail. + Se você tiver mais de uma moeda, precisará baixar cada arquivo \"transactions_export…\" e importá-lo para o Ivy. + Importar de + Aguarde + Importando o arquivo CSV + Concluído + Falha + Importado + %1$d transações + %1$d contas + %1$d categorias + Falha + %1$d linhas do arquivo CSV não reconhecidas + Concluir + Adicionar descrição + Descrição + Planejado para + Adicionar dinheiro a + Pagar com + De + Conta + Até + Adicionar conta + Título da Renda + Título da despesa + Transferir título + Despesas + Adicionar data de pagamento planejada + Pagar + Obter + Confirmar exclusão + A exclusão desta transação a removerá do histórico de transações e atualizará o saldo de acordo. + Confirmar alteração de conta + Observação: você está tentando alterar a conta associada ao empréstimo para uma conta de moeda diferente, \nTodos os registros de empréstimo serão recalculados com base nas taxas de câmbio de hoje + Confirmar + Aguarde, recalculando todos os registros de empréstimo + Criado em + Olá + Olá %1$s + Fluxo de caixa: %1$s%2$s %3$s + Pesquisar transações + Ivy Wallet é de código aberto + meta de economia + Acesso rápido + Configurações + Modo claro + Modo escuro + Modo automático + Pagamentos\nPlanejados + Compartilhar Ivy + Relatórios + Empréstimos + Definir moeda + Nenhuma transação + Você não tem transações para o período selecionado. + Você não tem transações para %1$s.\nVocê pode adicionar uma tocando no botão \"+\". + Adicionar empréstimo + Sem empréstimos + Você não tem nenhum empréstimo.\nToque em \"+ Adicionar empréstimo\" para adicionar um. + Observação: a exclusão deste empréstimo o excluirá permanentemente e removerá todos os registros de empréstimo associados. + Aguarde, recalculando todos os registros de empréstimo + Pago + %1$s restantes %2$s + Juros do empréstimo + %1$s %2$s pago + Adicionar registro + Interesse + Nenhum registro + Você não tem registros para este empréstimo. Toque em "Adicionar registro" para criar um. + Adicionar renda + Adicionar despesa + Não especificado + %1$s\%% + Transferências de conta + Você não tem transações para %1$s.\nVocê pode adicionar uma rolando para baixo e tocando no botão "Adicionar receita" ou "Adicionar despesas" na parte superior. + Observação: a exclusão desta conta a excluirá permanentemente e removerá todas as transações associadas a ela. + Observação: Excluir esta categoria a excluirá permanentemente. + Editar + transações + Início + Adicionar pagamento planejado + ADICIONAR RENDA + ADICIONAR DESPESAS + TRANSFERÊNCIAS DE CONTA + Pular + Adicionar novo + De %1$s + Para %1$s + Intervalo + Privacidade e\ndata coleta + Deslize para concordar com nossos Termos e Condições + Concorde com nossos Termos e Condições + Deslize para concordar com nossa política de privacidade + De acordo com nossa política de privacidade + Termos e condições + Política de Privacidade + Acompanhe suas receitas, despesas e orçamento com Ivy.\n\nInterface de usuário intuitiva, pagamentos programados e recorrentes, gerencie várias contas, organize transações em categorias, estatísticas significativas, exporte para CSV e muito mais. + Digite seu nome\para personalizar sua\ncarteira + Qual ​​é o seu nome? + Entrar + Adicionar contas + Sugestão + Próximo + Adicionar categorias + Sugestões + Definir + Seu gerente de dinheiro pessoal + #opensource + Erro. Tente novamente: %1$s + Fazendo login… + Sucesso! + Fazer login com o Google + conta offline + SINCRONIZE SEUS DADOS NA NUVEM Ivy + A integridade e a proteção dos dados não são garantidas! + OU ENTRE COM UMA CONTA OFFLINE + Seus dados serão salvos localmente (somente em seu telefone) e não serão sincronizados com a nuvem. Você corre o risco de perdê-lo se desinstalar o aplicativo ou alterar seu dispositivo. Você sempre pode ativar a sincronização mais tarde, se quiser. + + Ao entrar, você concorda com nossos %1$s e %2$s. + Importar arquivo CSV + de Ivy ou outro aplicativo + A importação de um arquivo de backup de outro pode levar até 5 minutos. Você sempre pode importar seus dados mais tarde, se quiser. + Importar arquivo de backup + Começar de novo + Excluir este pagamento planejado excluirá todas as transações não pagas futuras ou vencidas associadas a ele. + Definir tipo de pagamento + Início planejado em + REPETE A CADA %1$d %2$s + excluído + "PLANEJADO PARA " + null + "STARTS %1$s " + Adicionar pagamento + Pagamentos únicos + Pagamentos recorrentes + Nenhum pagamento planejado + Você não tem nenhum pagamento planejado.\nClique na parte inferior \'⚡\' para adicionar um. + Pagamentos planejados + Hoje + Ontem + Amanhã + Vence em %1$s + Próximo + Atrasado + despesas + renda + Editar conta + Nova conta + Nome da conta + Incluir conta + Insira o saldo da conta + Escolha a moeda + Calculadora + Cálculo (+-/*=) + Editar categoria + Criar categoria + Nome da categoria + Escolher categoria + Digite todos os detalhes aqui + Limpar filtros + Filtro + Aplicar filtros + Por tipo + Renda + Período de tempo + Selecionar intervalo de tempo + contas (%1$d) + Categorias (%1$d) + Limpar tudo + Selecionar tudo + Valor (opcional) + Palavras-chave (opcional) + INCLUDE + Adicionar uma palavra-chave + EXCLUI + Você não tem transações para seu filtro. + Sem filtro + Para gerar um relatório, você deve primeiro definir um filtro válido. + Definir filtro + Exportar + Você não tem transações para a consulta "%1$s". + + Dados de backup + Importar dados + Configurações do aplicativo + Bloquear o aplicativo + Mostrar notificações + Ocultar saldo + Clique no saldo oculto para mostrar o saldo por 5 segundos + Outro + Avalie-nos no Google Play + Compartilhar carteira Ivy + Produto + Zona de perigo + Excluir todos os dados do usuário + Excluir todos os dados do usuário? + AVISO! Esta ação excluirá todos os dados de %1$s PERMANENTEMENTE e você não poderá recuperá-los. + sua conta + Confirmar a exclusão permanente de \'%1$s\' + todos os seus dados + AVISO FINAL! Depois de clicar em "Excluir", seus dados desaparecerão para sempre. + Exportar dados + Aguarde, exportando dados + Data de início do mês + Ivy no Telegram + Central de Ajuda + Roteiro + Solicitar um recurso + Entre em contato com o suporte + Colaboradores do projeto + CONTA + Sair + Login + Sincronizando… + Dados sincronizados com a nuvem + Toque para sincronizar + Falha na sincronização. Toque para sincronizar + Anônimo + Exportar para CSV + Resta gastar + Orçamento excedido por + Buffer excedido por + Definir tipo de transação + Transferir + Selecionado + Pesquisar (USD, EUR, GBP, BTC etc.) + Pré-selecionado + Cripto + Taxa de câmbio + Escolha a cor + Reordenar + Palavra-chave + Editar orçamento + Criar orçamento + Nome do orçamento + MONTANTE DO ORÇAMENTO + Tem certeza de que deseja excluir o orçamento "%1$s"? + Editar meta de economia + Escolher ícone + Escolha o mês + ou intervalo personalizado + Adicionar data + ou no período + ou todo o período + Desmarcar todo o período + Selecionar todo o período + Todo o período + Escolha a data de início do mês + suporta criptografia + Excluir + Salvar + Adicionar + Criar + Editar empréstimo + Novo empréstimo + Nome do empréstimo + Conta associada + Criar uma transação principal + INSIRA O VALOR DO EMPRÉSTIMO + "Observação: você está tentando alterar a conta associada ao empréstimo para uma conta de moeda diferente. \nTodos os registros de empréstimo serão recalculados com base nas taxas de câmbio de hoje" + Tipo de empréstimo + Pedir empréstimo + Emprestar dinheiro + Editar registro + Novo registro + Observação + Marcar como interesse + Recalcular o valor com as taxas de câmbio de hoje + INSIRA O VALOR DO REGISTRO + Tem certeza de que deseja excluir o registro "%1$s"? + "Observação: você está tentando alterar a conta associada ao registro do empréstimo para uma conta de moeda diferente\nO valor será recalculado com base nas taxas de câmbio de hoje" + Editar nome + Planejar para + Uma vez + Várias vezes + Inicia em + Repete a cada + Enviar + O que você precisa? + Explique em uma frase. (Remarcação suportada) + últimos 12 meses + últimos 6 meses + últimas 4 semanas + Últimos 7 dias + Hoje, %1$s + Ontem, %1$s + Amanhã, %1$s + Expirado + Autenticação bem-sucedida! + Falha na autenticação. + Você fez alguma transação hoje? 🏁 + Você rastreou seus gastos hoje? 💸 + Você registrou suas transações hoje? 🏁 + Dinheiro + Banco + Revolução + + + Transporte + Mantimentos + Entretenimento + Compras + Presentes + Saúde + Investimentos + Carro + Trabalho + Restaurante + Família + Vida social + Pedidos de comida + Viagem + Condição física + Autodesenvolvimento + Roupas + Beleza + Educação + Animais de estimação + Esportes + Ajuste seu saldo inicial + para contas + Toque em uma conta -> Toque no seu saldo -> Insira o saldo atual. É isso!]]> + Crie seu primeiro pagamento planejado + Automatize o rastreamento de transações recorrentes, como suas assinaturas, aluguel, salário etc. Fique por dentro de suas finanças sabendo quanto você tem que pagar/receber antecipadamente. + Você sabia? + Ivy Wallet tem um widget legal que permite adicionar transações de RENDA/DESPESAS/TRANSFERS com 1 clique de sua casa\n\nObservação: se o botão "Adicionar Widget" não funcionar, adicione manualmente a partir do menu de widgets do seu iniciador. + Adicionar widget + Definir um orçamento + A Ivy Wallet não apenas ajuda você a acompanhar passivamente seus gastos, mas também cria proativamente seu futuro financeiro, definindo e cumprindo os orçamentos. + Você pode ver sua estrutura de despesas por categorias! Experimente, toque no botão cinza/preto Despesas logo abaixo do seu saldo. + Gráfico de pizza de despesas + Dê-nos a sua opinião sobre a Ivy Wallet + Dê-nos a sua opinião! Ajude a Ivy Wallet a melhorar e crescer escrevendo-nos uma avaliação. Elogios, ideias e críticas são bem-vindos! Fazemos o nosso melhor.\n\nAtenciosamente,\nEquipe Ivy + Ajude-nos a crescer para que possamos investir mais no desenvolvimento e melhorar o aplicativo para você. Ao compartilhar a Ivy Wallet, você fará dois desenvolvedores felizes e também ajudará um amigo a controlar suas finanças. + Compartilhar com amigos + Você pode gerar relatórios para obter informações detalhadas sobre suas receitas e despesas. Filtre suas transações por tipo, período, categoria, contas, valor, palavras-chave e muito mais para ter uma visão melhor de suas finanças. + Fazer um relatório + Você quer melhorar a Ivy Wallet? Escreva-nos uma avaliação. Só assim podemos desenvolver o que você quer e precisa. Isso também nos ajuda a ter uma classificação mais alta na PlayStore para que possamos gastar dinheiro no produto em vez de em marketing.\n\nFazemos o nosso melhor.\nEquipe Ivy + Precisamos da sua ajuda! + Somos apenas um designer e desenvolvedor trabalhando no aplicativo após nossos trabalhos de 9 a 5. Atualmente, gastamos muito tempo e dinheiro apenas para gerar desperdício e esgotamento. Se você quiser que continuemos desenvolvendo a Ivy Wallet, compartilhe-a com seus amigos e familiares.\n\nP.S. As avaliações da Google PlayStore também ajudam muito! + Ivy Wallet é de código aberto! + O código da Ivy Wallet está aberto e todos podem vê-lo. Acreditamos que a transparência e a ética são essenciais para qualquer produto de software. Se você gosta do nosso trabalho e deseja melhorar o aplicativo, pode contribuir com nosso repositório público do Github. + Contribuir + Ajustar Saldo + Autenticação necessária + Prove que você tem acesso a este dispositivo para desbloquear o aplicativo. + Orçamento total + Orçamento da categoria + Orçamento de várias categorias (%1$s) + EMPRESTADO + NASCIDO + Janeiro + Fevereiro + Março + Abril + Maio + Junho + Julho + Agosto + Setembro + Outubro + Novembro + Dezembro + dias + dia + semanas + semana + meses + mês + anos + ano + + Trate as transferências de conta como receita ou despesa na tela Contas + Início + Gerando relatório… + " Classificar por" + Pular tudo + Confirmar pular tudo + Tem certeza de que deseja pular todas as transações planejadas expiradas? + Mudar para o modo offline + AVISO! Esta ação excluirá todos os seus dados armazenados na nuvem por %1$s PERMANENTEMENTE, os dados offline armazenados em seu aplicativo local permanecerão. + Excluir todos os dados armazenados na nuvem? + Experimental + Configurações experimentais + Saldo da carteira + Marcar como subcategoria + Categoria principal + *Marcada como uma categoria pai + Descompactar todas as subcategorias + Marcar todos \"Optional columns\" nas opções de exportação + Renomear categoria de transferência + Para usuários não ingleses, renomeie a categoria do sistema de transferência para \"Transfer\" no arquivo de exportação + Ressalvas + As subcategorias não são suportadas + As colunas Evento, Pessoas e Local serão ignoradas + diff --git a/resources/src/main/res/values-ru/strings.xml b/resources/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..cc4cf4d --- /dev/null +++ b/resources/src/main/res/values-ru/strings.xml @@ -0,0 +1,440 @@ + + + Счета + Всего: %1$s %2$s + ДОХОДЫ ЗА МЕСЯЦ + РАСХОДЫ ЗА МЕСЯЦ + (исключенный) + ДОХОДЫ + РАСХОДЫ + Приложение заблокировано + Авторизуйтесь, чтобы зайти в приложение + Разблокировать + ТЕКУЩИЙ БАЛАНС + БАЛАНС ПОСЛЕ ЗАПЛАНИРОВАННЫХ ПЛАТЕЖЕЙ + Подключить + Синхронизировать транзации + Синхронизация транзакций… + Синхронизация с банком включена + Удалить клиента + Добавить бюджет + Бюджетов нет + У вас не настроены бюджеты.\nЧтобы добавить, нажмите \"+ Добавить бюджет\" + Бюджеты + %1$s %2$s для категорий + %1$s %2$s бюджет приложения + Информация о бюджете: %1$s / %2$s + Информация о бюджете: %1$s%2$s + Добавить категорию + Расходы + Количество расходов + Доходы + Количество доходов + График баланса + БАЛАНС %1$s + Графики + Период: + Категории + Экспорт CSV файла + Экспорт CSV файла со стандартными параметрами + Пожалуйста, используйте стандартные параметры и убедитесь, что в файле присутствуют заголовки + Как импортировать + открыть + Шаги + Как + Видео + Инструкция + Загрузить CSV файл + Экспорт данных + Загрузить CSV/ZIP файл + Экспортировать в файл + Кодировка: UTF-8\nДесятичный разделитель: Десятичная точка \'.\'\nСимвол-разделитель: Запятая \',\' + Экспорт Excel файла + Конвертировать XLS в CSV + Примечание! Если экспортированный файл не содержит расширения \".xls\", то добавьте его, пожалуйста, вручную. + Бесплатный онлайн CSV конвертер + Проверить папки \"Промоакции\" и \"Спам\" в вашей электронной почте + Скачать файл \"transactions_export…\", прикрепленный к письму + Если у вас более одной валюты, вам придётся загрузить каждый файл \"transactions_export…\" и импортировать его в Ivy + Загрузить из + Пожалуйста, подождите + Загружаем CSV файл + Успех + Произошла ошибка + Загружено + %1$d транзакций + %1$d счетов + %1$d категорий + Не удалось + Не удалось распознать %1$d строк из CSV файла + Завершить + Добавить описание + Описание + Запланировано на + Добавить деньги в + Оплатить с + Из + Счет + В + Добавить счет + Заголовок дохода + Заголовок расхода + Заголовок перевода + Расход + Добавить дату запланированного платежа + Заплатить + Получить + Подтвердите удаление + Удаление этой транзакции приведет к её удалению из истории транзакций и соответствующему обновлению баланса + Подтвердите изменение счёта + Примечание. Вы пытаетесь изменить счёт, связанный с кредитом со счётом в другой валюте,\nвсе записи о кредите будут пересчитаны на основе сегодняшних обменных курсов + Подтвердить + Пожалуйста, подождите. Пересчитываем все данные по кредиту + Создано + Привет + Привет, %1$s + Денежный поток: %1$s%2$s %3$s + Поиск транзакций + Ivy Wallet — open-source проект + Цель накоплений + Быстрый доступ + Настройки + Светлая\nтема + Тёмная\nтема + Системная\nтема + Запланированные платежи + Поделись Ivy + Отчёты + Кредиты + Установите валюту + Транзакций нет + У вас нет ни одной транзакции за %1$s. Чтобы добавить, нажмите кнопку \"+\" + Добавить кредит + Кредитов нет + У вас нет ни одного кредита.\nЧтобы добавить, нажмите \"+ Добавить кредит\" + Примечание: Удаление этого кредита приведёт к его полному удалению, включая все записи, связанные с этим кредитом + Пожалуйста, подождите. Идет пересчитывание всех записей по кредиту + Оплачено + %1$s %2$s осталось + Проценты по кредиту + %1$s %2$s оплачено + Добавить запись + Проценты + Нет записей + У вас нет ни одной записи по этому кредиту.\nНажмите на \"Добавить запись\", чтобы внести запись. + Добавить доход + Добавить расход + Не определено + %1$s\%% + Переводы + У вас нет транзакций за %1$s.\nЧтобы добавить, нажмите кнопку \"Добавить доход\" или \"Добавить расход\", расположенную выше. + Примечание: Удаление этого счёта приведёт к его окончательному удалению и удалению всех связанных с ним транзакций. + Примечание: При удалении этой категории, она будет удалена навсегда. + Редактировать + транзакции + Главная + Добавить запланированный платёж + ДОБАВИТЬ ДОХОД + ДОБАВИТЬ РАСХОД + ПЕРЕВОД + Пропустить + Добавить новое + От %1$s + До %1$s + Диапазон + Конфиденциальность и\nсбор данных + Смахните, чтобы согласиться с нашими условиями + Согласен с условиями + Смахните, чтобы согласиться с наший политикой конфиденциальности + Согласен с политикой конфиденциальности + Условия + Политика конфиденциальности + Отслеживайте ваши доходы, расходы и бюджет с Ivy.\n\nИнтуитивно понятный интерфейс, регулярные и запланированные платежи, управляй несколькими счетами, организуй транзакции по категориям, используй полезную статистику, экспортируй в CSV и многое другое. + Для персонализации вашего кошелька введите своё имя + Как вас зовут? + Ввести + Добавить счета + Предложения + Далее + Добавить категории + Предложения + Установить + Ваш личный финансовый менеджер + #opensource + Ошибка. Попробуйте снова: %1$s + Выполняется вход… + Успешно! + Войти с помощью Google + Оффлайн аккаунт + СИНХРОНИЗИРУЙТЕ ВАШИ ДАННЫЕ В IVY CLOUD + Целостность и защищённость данных не гарантирована! + ИЛИ ВОЙДИТЕ ЧЕРЕЗ ОФФЛАЙН АККАУНТ + Ваши данные будут сохранены локально (на вашем телефоне) и не будут синхронизированы в облаке. Вы рискуете потерять данные, если удалите приложение или смените устройство. Вы всегда можете активировать синхронизацию позже. + + Входя в систему, вы поглашаетесь с %1$s и %2$s. + Загрузите CSV файл + из Ivy или другого приложения + Загрузка бэкап файла из другого приложения может занять до 5 минут. Вы всегда можете загрузить данные позже. + Загрузить бэкап файл + Начать сначала + Удаление запланированного платежа приведёт к удалению всех предстоящих неоплаченных и просроченных транзакций, связанных с этим платежём. + Установить тип платежа + Запланирован старт на + ПОВТОРЯЕТСЯ КАЖДЫЕ %1$d %2$s + удалено + "ЗАПЛАНИРОВАН НА " + null + "НАЧИНАЕТСЯ С %1$s " + Добавить платёж + Одноразовые платежи + Повторяющиеся платежи + Нет запланированных платежей + У вас нет ни одного запланированного платежа.\nНажмите кнопку \'⚡\' внизу, чтобы добавить. + Запланированные платежи + Сегодня + Вчера + Завтра + Выполнить к %1$s + Предстоящие + Просроченные + расходы + доходы + Редактировать счёт + Новый счёт + Название счёта + Включить счёт + Введите баланс счёта + Выберите валюту + Калькулятор + Подсчёт (+-/*=) + Редактировать категорию + Создать категорию + Название категории + Выбрать категорию + Добавьте детали + Очистить фильтр + Фильтр + Применить фильтр + По типу + Доходы + Временной период + Выберите временной промежуток + Счета (%1$d) + Категории (%1$d) + Очистить всё + Выбрать всё + Количество (опционально) + Ключевые слова (опционально) + ВКЛЮЧАЕТ + Добавьте ключевое слово + НЕ ВКЛЮЧАЕТ + У вас нет ни одной транзакции по этому фильтру. + Нет фильтров + Чтобы сгенерировать отчёт, сначала установите фильтр. + Установить фильтр + Экспорт + По вашему запросу не нашлось ни одной транзакции: %1$s. + + Бэкап данных + Импорт данных + Настройки приложения + Заблокировать приложение + Показывать уведомления + Скрыть баланс + Нажмите на скрытый баланс, чтобы показать его на 5 секунд + Другое + Оцените нас в Google Play + Поделитесь Ivy Wallet + Продукт + Опасная зона + Удалить все данные пользователя + Удалить все данные пользователя? + ВНИМАНИЕ! Это действие ОКОНЧАТЕЛЬНО удалит все данные для %1$s, и у вас не будет возможности их восстановить. + вашей учётной записи + Подтвердите окончательное удаление для \'%1$s\' + всех ваших данных + ОКОНЧАТЕЛЬНОЕ ПРЕДУПРЕЖДЕНИЕ! После нажатия \"Удалить\" ваши данные будут удалены навсегда. + Экспорт данных + Пожалуйста, подождите. Идёт экспорт данных. + Начало месяца + Ivy Telegram + Справочный центр + Roadmap + Запроcить новую функциональность + Связь со службой поддержки + Участники проекта + УЧЁТНАЯ ЗАПИСЬ + Выйти + Войти + Синхронизация… + Данные синхронизированы с облаком + Нажмите, чтобы синхронизировать + Синхронизация не удалась. Нажмите, чтобы повторить + Аноним + Экспорт в CSV + Осталось потратить + Бюджет превышен на + Буфер превышен на + Установить тип транзакции + Перевод + Выбран + Поиск (USD, EUR, GBP, BTC и т.д.) + Предварительно выбранный + Криптовалюта + Обменный курс + Выберите цвет + Изменить порядок + Ключевое слово + Редактировать бюджет + Создать бюджет + Название бюджета + СУММА БЮДЖЕТА + Вы уверены, что хотите удалить %1$s бюджет? + Редактировать цель накоплений + Изменить иконку + Изменить месяц + или пользовательский диапазон + Добавьте дату + или в последний + или за всё время + Отменить \"За всё время\" + Выбрать \"За всё время\" + Выберите начальную дату месяца + поддерживает криптовалюты + Удалить + Сохранить + Добавить + Создать + Редактировать кредит + Новый кредит + Название кредита + Связанный счёт + Создать основную транзакцию + ВВЕДИТЕ СУММУ КРЕДИТА + "Примечание: Вы пытаетесь изменить счёт, связанный с кредитом в другой валюте.\nВсе записи о кредите будут пересчитаны на основе обменного курса за сегодня " + Тип кредита + Занять деньги + Одолжить денег + Редактировать запись + Новая запись + Заметка + Отметить как \"Интересно\" + Пересчитать сумму по сегодняшнему обменному курсу + ВВЕДИТЕ СУММУ ЗАПИСИ + Вы уверены, что вы хотите удалить %1$s запись? + "Примечание: Вы пытаетесь изменить счёт, связанный с записью о кредите, связанном с другой валютой.\nСумма будет пересчитана на основе сегодняшнего обменного курса " + Редактировать имя + Запланировать на + Один раз + Много раз + Начинается с + Повторяется каждый + Отправить + Что вам нужно? + Опишите это в одном предложении. (поддерживает markdown) + Последние 12 месяцев + Последние 6 месяцев + Последние 4 недели + Последние 7 дней + Сегодня, %1$s + Вчера, %1$s + Завтра, %1$s + Просрочено + Аутентификация успешна! + Аутентификация не удалась. + Вы сегодня совершали какие-либо транзакции? 🏁 + Вы сегодня отслеживали свои расходы? 💸 + Вы записали свои транзакции за сегодня? 🏁 + Наличные + Банк + Revolut + + + Транспорт + Продукты + Развлечения + Покупки + Подарки + Здоровье + Инвестиции + Машина + Работа + Ресторан + Семья + Социальная жизнь + Доставка еды + Путешествия + Фитнес + Саморазвитие + Одежда + Красота + Образование + Домашнее животное + Спорт + Настройте исходный баланс + К просмотру счетов + Нажмите на счёт -> Нажмите на его баланс -> Введите текущий баланс. Вот и всё!]]> + Создайте свой первый запланированный платёж + Автоматизируйте отслеживание повторяющихся транзакций, таких как: подписки, арендная плата, заработная плата и т.д. Опережайте свои финансы, зная заранее, сколько вы должны заплатить/получить. + А вы знали? + У Ivy Wallet есть крутые виджеты, с помощью которых можно добавить транзакции ДОХОДЫ/РАСХОДЫ/ПЕРЕВОД за один клик со стартового экрана вашего смартфона.\n\nПримечание: Примечание: Если кнопка \"Добавить виджет\" не работает, пожалуйста, добавьте его вручную из меню виджетов. + Добавить виджет + Установить бюджет + Ivy Wallet помогает не только пассивно отслеживать ваш бюджет, но и проактивно создавать ваше финансовое будущее, задавая бюджеты и придерживаясь его. + Вы можете видеть структуру ваших расходов по категориям! Попробуйте, нажмите на серую/чёрную кнопку \"Расходы\" прямо под вашим балансом. + Круговая диаграмма Расходов + Оцените Ivy Wallet + Поделитесь обратной связью! Помогите Ivy Wallet стать лучше и вырасти, оставив нам отзыв. Комплименты, идеи и критика приветствуются! Мы очень стараемся.\n\С наилучшими пожеланиями,\nКоманда Ivy. + Помогите нам расти, чтобы мы могли больше вкладываться в разработку и делать приложение лучше для вас. Если вы поделитесь Ivy Wallet, вы обрадуете двух разработчиков и поможете друзьям взять под контроль их финансы. + Поделитесь с друзьями + Вы можете генерировать отчёты, чтобы получить глубокое понимание ваших доходов и трат. Фильтруйте ваши транзакции по типу, периоду времени, категории, учётным записям, количеству, ключевым словам и многому другому, чтобы получить более репрезентативную картину ваших финансов. + Сделайте отчёт + Хотите сделать Ivy Wallet лучше? Напишите отзыв. Для нас это единственный способ разрабатывать именно то, что вы хотите и что вам нужно. Также, это помогает нам подняться в рейтинге в PlayStore, что помогает нам больше вкладываться в разработку, и меньше - в рекламу.\n\nМы очень стараемся.\nКоманда Ivy. + Нам нужна ваша помощь! + Мы всего лишь разработчик и дизайнер, работающие над приложение после основной работы. На данный момент мы вложили много денег и времени, но не получили ничего. Если вы хотите, чтобы мы продолжали разработку Ivy Wallet, пожалуйста, поделитесь ссылкой на приложение с друзьями и родственниками.\n\nP.S. отзывы в Google PlayStore тоже очень помогают! + Ivy Wallet - это open-source проект! + Код Ivy Wallet открыт и любой может его посмотреть. Мы верим, что прозрачность и этика - это обязательные качества для любого программного продукта. Если вам нравится наша работа и вы хотите сделать приложение лучше, вы можете внести свой вклад в разработку в нашем публичном репозитории на Github. + Внести свой вклад + Скорректировать баланс + Аутентификация не удалась + Подтвердите, что у вас есть доступ к этому устройсту, чтобы разблокировать приложение. + Общий бюджет + Бюджет категории + Многокатегорийный (%1$s) бюджет + ОДОЛЖИЛИ + ДАЛИ ВЗАЙМЫ + Январь + Февраль + Март + Апрель + Май + Июнь + Июль + Август + Сентябрь + Октябрь + Ноябрь + Декабрь + дней + день + недель + неделя + месяцев + месяц + года + год + + Переводы счёта рассматриваются как доходы и расходы на экране \"Счета\" + Дом + Создание отчёта… + Сортировать по + Пропустить все + Подтвердить пропуск всех + Вы уверены, что хотите пропустить все просроченные запланированные операции? + Switch to offline mode + WARNING! This action will delete all your cloud-stored data for %1$s PERMANENTLY, the offline data stored in your local app will remain. + Delete all cloud-stored data? + Experimental + Experimental Settings + Wallet balance + diff --git a/resources/src/main/res/values-ta/strings.xml b/resources/src/main/res/values-ta/strings.xml new file mode 100644 index 0000000..b378769 --- /dev/null +++ b/resources/src/main/res/values-ta/strings.xml @@ -0,0 +1,450 @@ + + + கணக்குகள் + மொத்தம்: %1$s %2$s + இம்மாத வருவாய் + இம்மாதச் செலவுகள் + (விலகியவை) + வருவாய் + செலவுகள் + செயலி பூட்டப்பட்டது + செயலியுள் நுழைய அங்கீகரி + பூட்டவிழ் + தற்போதைய இருப்பு + திட்டமிட்ட கட்டணங்களுக்குப் பின் இருப்பு + இணை + பரிவர்த்தனைகளை ஒத்திசை + பரிவர்த்தனைகளை ஒத்திசைக்கிறது… + Bank sync enabled: + நுகர்வோரை நீக்கு + பாதீட்டைச் சேர் + பாதீடுகள் இல்லை + நீங்கள் பாதீடுகள் ஏதும் அமைக்கவில்லை.\nஒன்றைச் சேர்க்க "+ பாதீட்டைச் சேர்"ஐத் தட்டுக. + பாதீடுகள் + %1$s %2$s for categories + %1$s %2$s app budget + பாதீடு விவரம்: %1$s / %2$s + பாதீடு விவரம்: %1$s%2$s + பகுப்பைச் சேர் + செலவுகள் + செலவுகள் எண்ணிக்கை + வருவாய் + வருவாய் எண்ணிக்கை + இருப்பு விளக்கப்படம் + இருப்பு %1$s + விளக்கப்படம் + Period: + பகுப்புகள் + CSV கோப்பை ஏற்றுமதிசெய் + Export CSV file with standard options + Please use the standard options and make sure to include headers. + எப்படி இறக்குமதிசெய்வது + திற + வழிமுறை + எப்படி + காணொளி + Article + CSV கோப்பைப் பதிவேற்று + தரவை ஏற்றுமதிசெய் + CSV/ZIP கோப்பைப் பதிவேற்று + கோப்பிற்கு ஏற்றுமதிசெய் + வரியுரு தொகுப்பு: UTF-8\nDecimal separator: Decimal point \'.\'\nDelimiter character: Comma \',\' + Excel கோப்பை ஏற்றுமதிசெய் + XLSஐ CSVக்கு மாற்று + !குறிப்பு: ஏற்றுமதிசெய்த கோப்பில் ".xls" நீட்டிப்பு இல்லையெனில், கைமுறையாகக் கோப்பை மறுபெயரிட்டு அதைச் சேர்ப்பீர். + Online CSV converter FREE + உமது மின்னஞ்சலின் "விளம்பரங்கள்" மற்றும் "ஸ்பேம்" கோப்புறைகளைச் சரிபார் + மின்னஞ்சலுக்கு இணைக்கப்பட்ட \"transactions_export…\" என்ற கோப்பைப் பதிவிறக்கு. + If you have more than one currency you\'ll have to download each \"transactions_export…\" file and import it in Ivy. + இதிலிருந்து இறக்குமதிசெய்ய + காத்திருக்கவும் + CSV கோப்பை இறக்குமதிசெய்கிறது + வெற்றி + தோல்வி + இறக்குமதியானது + %1$d பரிவர்த்தனைகள் + %1$d கணக்குகள் + %1$d பகுப்புகள் + தோல்வி + %1$d rows from CSV file not recognized + முடித்திடு + விளக்கவுரை சேர் + விளக்கவுரை + Planned for + இதற்கு பணத்தைச் சேர் + இதனுடன் கட்டு + From + கணக்கு + To + கணக்குச் சேர் + வருவாய் தலைப்பு + செலவு தலைப்பு + Transfer title + செலவு + திட்டமிட்ட கட்டணத்தேதியைச் சேர் + கட்டு + பெறு + அழிப்பதை உறுதிசெய் + இப்பரிவர்த்தனையை அழித்தல் இதைப் பரிவர்த்தனை வரலாற்றிலிருந்து நீக்கி இருப்பை அதற்கேற்றாற்போல் புதுப்பிக்கும். + கணக்கு மாற்றத்தை உறுதிசெய் + Note: You are trying to change the account associated with the loan with an account of different currency, \nAll the loan records will be re-calculated based on today\'s exchanges rates + உறுதிசெய் + காத்திருக்கவும், எல்லா கடன் பதிவுகளையும் மறுகணக்கிடுகிறது + Created on + வணக்கம் + வணக்கம் %1$s + பணப்புழக்கம்: %1$s%2$s %3$s + பரிவர்த்தனைகளைத் தேடு + ஐவி வாலெட் திறந்த மூலம் ஆகும் + சேமிப்பு இலக்கு + விரைவணுகல் + அமைப்புகள் + ஒளிர்ந்த பயன்முறை + இருண்ட பயன்முறை + தானியங்கு பயன்முறை + திட்டமிடப்பட்ட\nகட்டணங்கள் + ஐவியைப் பகிர் + அறிக்கைகள் + கடன்கள் + நாணயத்தை அமை + பரிவர்த்தனைகள் இல்லை + You don\'t have any transactions for %1$s.\nYou can add one by tapping the \"+\" button. + கடனைச் சேர் + கடன்கள் இல்லை + You don\'t have any loans.\nTap the \"+ Add loan\" to add one. + Note: Deleting this loan will remove it permanently and delete all associated loan records with it. + Please wait, re-calculating all loan records + கட்டப்பட்டது + %1$s %2$s மீதம் + கடன் வட்டி + %1$s %2$s கட்டப்பட்டது + பதிவைச் சேர் + வட்டி + பதிவுகள் இல்லை + You don\'t have any records for this loan. Tap "Add record" to create one. + வருவாயைச் சேர் + செலவைச் சேர் + குறிப்பிடப்படாதது + %1$s\%% + கணக்கு இடமாற்றங்கள் + You don\'t have any transactions for %1$s.\nYou can add one by scrolling down and tapping "Add income" or "Add expense" button at the top. + Note: Deleting this account will remove it permanently and delete all associated transactions with it. + Note: Deleting this category will remove it permanently. + திருத்து + பரிவர்த்தனைகள் + முகப்பு + திட்டமிடப்பட்ட கட்டணத்தைச் சேர் + வருவாயைச் சேர் + செலவைச் சேர் + கணக்கு இடமாற்றம் + கெந்து + புதிதைச் சேர் + %1$s முதல் + %1$s வரை + வீசுகளம் + தனியுரிமை மற்றும்\nதரவு திரட்சி + எமது வழிமுறைகள் மற்றும் வரையறைகளை ஏற்க தேய்க்கவும் + எமது வழிமுறைகள் மற்றும் வரையறைகளை ஏற்றீர் + எமது தனியுரிமை கொள்கையை ஏற்க தேய்க்கவும் + எமது தனியுரிமை கொள்கையை ஏற்றீர் + வழிமுறைகள் மற்றும் வரையறைகள் + தனியுரிமை கொள்கை + Track your income, expenses and budget with Ivy.\n\nIntuitive UI, recurring and planned payments, manage multiple accounts, organize transactions in categories, meaningful statistics, export to CSV and so much more. + Enter your name\nto personalize your\nwallet + உமது பெயர் என்ன? + உள்ளிடு + கணக்குகளைச் சேர் + பரிந்துரைகள் + அடுத்து + பகுப்புகளைச் சேர் + பரிந்துரைகள் + அமை + உமது ஆட்சார்புடைய பண நிர்வாகி + #opensource + பிழை. மீண்டும் முயல்க: %1$s + உள்நுழைகிறது… + வெற்றி! + கூகுளுடன் உள்நுழை + Offline account + ஐவி மேகத்தரவில் உமது தரவை ஒத்திசை + தரவு ஒருமைப்பாடு மற்றும் காப்பிற்கு உத்தரவாதம் இல்லை! + OR ENTER WITH OFFLINE ACCOUNT + Your data will be saved locally (only on your phone) and won\'t be synced with the cloud. You risk losing it if you uninstall the app or change your device. You can always activate sync later if you decide to. + + உள்நுழைவதன் மூலம், எமது %1$s மற்றும் %2$sஐ ஏற்கிறீர். + CSV கோப்பை இறக்குமதிசெய் + ஐவி அல்லது மற்ற செயலியிலிருந்து + Importing a backup file from another can take up to 5 min. You can always import your data later if you want to. + காப்புநகல் கோப்பை இறக்குமதிசெய் + புதிதாகத் துவங்கு + Deleting this planned payment will delete all non-paid upcoming or overdue transactions associated with it. + கட்டண வகையை அமை + திட்டமிட்ட துவக்க நேரம் + REPEATS EVERY %1$d %2$s + அழிக்கப்பட்டது + "PLANNED FOR " + null + "STARTS %1$s " + கட்டணத்தைச் சேர் + ஒருமுறைக் கட்டணங்கள் + தொடர்ச்சிக் கட்டணங்கள் + திட்டமிடப்பட்ட கட்டணங்கள் இல்லை + உம்மிடம் திட்டமிடப்பட்ட கட்டணங்கள் இல்லை.\nஒன்றைச் சேர்க்க அடிப்புறத்தில் இருக்கும் \'⚡\' பொத்தானை அழுத்து. + திட்டமிடப்பட்ட கட்டணங்கள் + இன்று + நேற்று + நாளை + Due on %1$s + வரவிருப்பவை + கெடுமுடிவு + செலவுகள் + வருவாய் + கணக்கைத் திருத்து + புதிய கணக்கு + கணக்கு பெயர் + கணக்கை உள்ளடக்கு + கணக்கு இருப்பை உள்ளிடு + நாணயத்தைத் தெரிவுசெய் + கணிப்பான் + Calculation (+-/*=) + பகுப்பைத் திருத்து + பகுப்பை உருவாக்கு + பகுப்பு பெயர் + பகுப்பைத் தெரிவுசெய் + விவரம் ஏதேனையும் இங்கு உள்ளிடுக + வடிகட்டியைத் துடை + வடிகட்டி + வடிகட்டியைச் செயல்படுத்து + வகையால் + வருவாய்கள் + Time Period + நேர வீசுகளத்தைத் தேர்ந்தெடு + கணக்குகள் (%1$d) + பகுப்புகள் (%1$d) + எல்லாம் துடை + எல்லாம் தேர்ந்தெடு + தொகை (விரும்பினால்) + குறிச்சொல் (விரும்பினால்) + INCLUDES + குறிச்சொல்லைச் சேர் + EXCLUDES + You don\'t have any transactions for your filter. + வடிகட்டி இல்லை + அறிக்கையை உருவாக்க ஒரு செல்லும் வடிகட்டியை அமைப்பீர் + வடிகட்டியை அமை + ஏற்றுமதிசெய் + You don\'t have any transactions for "%1$s" query. + + தரவைக் காப்புநகலெடு + தரவை இறக்குமதிசெய் + செயலி அமைப்புகள் + செயலியைப் பூட்டு + அறிவிப்புகளைக் காட்டு + இருப்பை மறை + Click on the hidden balance to show the balance for 5s + மற்றவை + கூகுள் பிளேயில் எம்மை மதிப்பிடுவீர் + ஐவி வாலெட்டைப் பகிர் + Product + ஆபத்து மண்டலம் + எல்லா பயனர் தரவையும் அழி + எல்லா பயனர் தரவையும் அழிக்கவா? + எச்சரிக்கை! இச்செயல் %1$s-க்கான எல்லா தரவையும் நிரந்தரமாக அழிக்கும் மற்றும் உம்மால் அவற்றை மீட்டெடுக்க இயலாது. + உமது கணக்கு + \'%1$s\'-க்கான நிரந்தர அழிப்பை உறுதிபடுத்துக + உமது தரவு அனைத்தும் + கடைசி எச்சரிக்கை! "அழி" என்பதைச் சொடுக்கியவுடன் உமது தரவனைத்தும் என்றும் காணாமல்போய்விடும். + தரவை ஏற்றுமதிசெய்கிறது + காத்திருக்கவும், தரவை ஏற்றுமதிசெய்கிறது + மாதத்தின் துவக்க தேதி + ஐவி டெலகிராம் + உதவி மையம் + பயணத்திட்டம் + ஓர் அம்சத்தைக் கோரு + உதவியைத் தொடர்புகொள் + வினைத்திட்ட பங்களிப்பாளர்கள் + கணக்கு + வெளியேறு + உள்நுழை + ஒத்திசைக்கிறது… + மேகத்தரவில் தரவு ஒத்திசைக்கப்பட்டது + ஒத்திசைக்கத் தட்டு + ஒத்திசைவு தோல்வி. ஒத்திசைக்கத் தட்டு + அநாமதேய + CSVக்கு ஏற்றுமதிசெய் + Left to spend + Budget exceeded by + Buffer exceeded by + பரிவர்த்தனை வகையை அமை + பணமாற்று + Selected + Search (USD, EUR, GBP, BTC, etc) + Pre-selected + Crypto + பரிமாற்ற விகிதம் + நிறத்தைத் தெரிவுசெய் + மறுசீரமை + குறிச்சொல் + பாதீட்டைத் திருத்து + பாதீட்டை உருவாக்கு + பாதீடு பெயர் + பாதீடு தொகை + "%1$s" பாதீட்டை அழிப்பதில் உறுதியா? + சேமிப்பு இலக்கைத் திருத்து + படவுருவைத் தெரிவுசெய் + மாதத்தைத் தெரிவுசெய் + அல்லது விருப்ப வீசுகளம் + தேதியைச் சேர் + அல்லது கடைசியில் + அல்லது எல்லா நேரம் + எல்லா நேரத்தைத் தேர்ந்தெடுக்காதே + எல்லா நேரத்தைத் தேர்ந்தெடு + மாதத்தின் துவக்க தேதியைத் தெரிவுசெய் + supports crypto + அழி + சேமி + சேர் + உருவாக்கு + கடனைத் திருத்து + புதிய கடன் + கடன் பெயர் + தொடர்புறு கணக்கு + முதன்மை பரிவர்த்தனையை உருவாக்கு + கடன் தொகையை உள்ளிடு + "Note: You are trying to change the account associated with the loan with an account of different currency, \nAll the loan records will be re-calculated based on today's exchanges rates " + கடன் வகை + பணத்தைக் கடன்பெறு + பணத்தைக் கடன்கொடு + பதிவைத் திருத்து + புதிய பதிவு + Note + ஆர்வமாகக் குறி + Recalculate Amount with today\'s Currency exchange Rates + பதிவுத் தொகையை உள்ளிடு + "%1$s" பதிவை அழிப்பதில் உறுதியா? + "Note: You are trying to change the account associated with the loan record with an account of different currency\nThe amount will be re-calculated based on today's exchanges rates " + பெயரைத் திருத்து + Plan for + ஒருமுறை + பலமுறை + Starts on + Repeats every + சமர்ப்பி + உமக்கென்ன தேவை? + ஒரு வாக்கியத்தில் விவரி. (markdownஐ ஆதிரிக்கிறது) + கடந்த 12 மாதங்கள் + கடந்த 6 மாதங்கள் + கடந்த 4 வாரங்கள் + கடந்த 7 நாட்கள் + இன்று, %1$s + நேற்று, %1$s + நாளை, %1$s + காலாவதுயானது + அங்கீகரிப்பு வெற்றி! + அங்கீகரிப்பு தோல்வி. + இன்று ஏதேனும் பரிவர்த்தனை செய்தீரா? 🏁 + Did you track your expenses today? 💸 + இன்று உமது பரிவர்த்தனைகளைப் பதிவுசெய்தீரா? 🏁 + ரொக்கம் + வங்கி + Revolut + + + போக்குவரத்து + மளிகைகள் + கேளிக்கை + கடைவலம் + பரிசுகள் + உடல்நலம் + முதலீடுகள் + மகிழுந்து + வேலை + உணவகம் + குடும்பம் + சமூக வாழ்க்கை + வரவழைத்த உணவு + பயணம் + திடகாத்திரம் + சுய-வளர்ச்சி + ஆடைகள் + அழகு + கல்வி + செல்லப்பிராணி + விளையாட்டுகள் + துவக்க இருப்பை அனுசரி + கணக்குகளுக்கு + Tap an account -> Tap its balance -> Enter current balance. That\'s it!]]> + உமது முதல் திட்டமிடப்பட்ட கட்டணத்தை உருவாக்கு + Automate the tracking of recurring transactions like your subscriptions, rent, salary, etc. Stay ahead of your finances by knowing how much you have to pay/get in advance. + உமக்குத் தெரியுமா? + Ivy Wallet has a cool widget that lets you add INCOME/EXPENSES/TRANSFER transactions with 1-click from your home\n\nNote: If the "Add widget" button doesn\'t work, please add it manually from your launcher\'s widgets menu. + Add widget + Set a budget + Ivy Wallet not only helps you to passively track your expenses but also proactively create your financial future by setting budgets and sticking to them. + You can see your expenses structure by categories! Try it, tap the gray/black Expenses button just below your balance. + Expenses PieChart + Review Ivy Wallet + Give us your feedback! Help Ivy Wallet become better and grow by writing us a review. Compliments, ideas, and critics are all welcome! We do our best.\n\nCheers,\nIvy Team + Help us grow so we can invest more in development and make the app better for you. By sharing Ivy Wallet you\'ll make two developers happy and also help a friend to take control of their finances. + Share with friends + You can generate reports to get deep insights about your income and spending. Filter your transactions by type, time period, category, accounts, amount, keywords and more to gain better view on your finances. + Make a report + Want to make Ivy Wallet better? Write us a review. That\'s the only way for us to develop what you want and need. Also it help us rank higher in the PlayStore so we can spend money on the product rather than marketing.\n\nWe do our best.\nIvy Team + எமக்கு உமது உதவி தேவை! + We\'re just a designer and a developer working on the app after our 9–5 jobs. Currently, we invest a lot of time and money to generate only losses and exhaustion. If you want us to keep developing Ivy Wallet please share it with friends and family.\n\nP.S. Google PlayStore reviews also helps a lot! + Ivy Wallet is open-source! + Ivy Wallet\'s code is open and everyone can see it. We believe that transparency and ethics are must for every software product. If you like our work and want to make the app better you can contribute in our public Github repository. + பங்களி + இருப்பை அனுசரி + அங்கீகரிப்பு தேவை + Prove that you have access to this device to unlock the app. + மொத்த பாதீடு + பகுப்பு பாதீடு + பற்பகுப்பு (%1$s) பாதீடு + கடன்பெற்ற + கடனளித்த + ஜனவரி + பிப்ரவரி + மார்ச் + ஏப்ரல் + மே + ஜூன் + ஜூலை + ஆகஸ்ட் + செப்டம்பர் + அக்டோபர் + நவம்பர் + டிசம்பர் + நாட்கள் + நாள் + வாரங்கள் + வாரம் + மாதங்கள் + மாதம் + ஆண்டுகள் + ஆண்டு + + Treats account transfers as income or expense in Accounts Screen + வீடு + அறிக்கை உண்டாக்குகிறது… + இப்படி வரிசைபடுத்து + எல்லாம் கெந்து + எல்லா கெந்தலை உறுதிபடுத்து + Are you sure that you want to skip all overdue planned payments? + Switch to offline mode + WARNING! This action will delete all your cloud-stored data for %1$s PERMANENTLY, the offline data stored in your local app will remain. + Delete all cloud-stored data? + பரீட்சார்த்த + பரீட்சார்த்த அமைப்புகள் + பணப்பை இருப்பு + Mark as Sub-Category + Parent Category + *This is marked as a Parent Category + Unpack All Subcategories + Check all \"Optional columns\" in export options + Rename transfer category + For non-English users, rename transfer system category to \"Transfer\" in export file + Caveats + Subcategories are not supported + Event, People and Place columns will be ignored + diff --git a/resources/src/main/res/values-uk/strings.xml b/resources/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000..4e6824c --- /dev/null +++ b/resources/src/main/res/values-uk/strings.xml @@ -0,0 +1,466 @@ + + + Рахунки + Всього: %1$s %2$s + ДОХІД ЗА МІСЯЦЬ + ВИТРАТИ ЗА МІСЯЦЬ + (виключено) + ДОХОДИ + ВИТРАТИ + ДОДАТОК ЗАБЛОКОВАНО + Авторизуйтесь, щоб увійти в застосунок + Розблокувати + ПОТОЧНИЙ БАЛАНС + БАЛАНС ПІСЛЯ ЗАПЛАНОВИХ ПЛАТЕЖІВ + Підключитися + Синхронізувати транзакції + Синхронізація транзакцій… + Синхронізація з банком ввімкнена: + Видалити клієнта + Додати бюджет + Бюджети відсутні + У вас не встановлено жодного бюджету.\nНатисніть \"+ Додати бюджет\", щоб додати його. + Бюджети + %1$s %2$s для категорій + %1$s %2$s бюджет застосунку + Інформація про бюджет: %1$s / %2$s + Інформація про бюджет: %1$s%2$s + Додати категорію + Витрати + Кількість витрат + Доходи + Кількість доходів + Діаграма балансу + БАЛАНС %1$s + Діаграми + Період: + Категорії + Експорт CSV файлу + Експорт CSV файлу зі стандартними параметрами + Використовуйте стандартні параметри та обов\'язково додайте заголовки. + Як імпортувати + відкрити + Кроки + Як + Відео + Інструкція + Завантажити CSV файл + Експорт даних + Завантажити CSV/ZIP файл + Експорт у файл + Кодування: UTF-8\nДесятковий роздільник: Десяткова точка \'.\'\nСимвол роздільника: Кома \',\' + Експорт Excel файлу + Конвертувати XLS в CSV + !ПРИМІТКА: Якщо експортований файл не має розширення \".xls\", додайте його, перейменувавши файл вручну. + Безкоштовний онлайн CSV конвертер + Перевірте папки \"Промоакції\" та \"Спам\" вашої електронної пошти. + Завантажте файл \"transactions_export…\" прикріплений до електронного листа. + Якщо у вас більше ніж одна валюта, вам доведеться завантажити кожен файл \"transactions_export…\" та імпортувати його в Ivy. + Імпортувати з + Будь ласка, зачекайте + Імпорт CSV файлу + Успіх + Виникла помилка + Завантажено + %1$d транзакцій + %1$d рахунків + %1$d категорій + Не вдалося + Не вдалось розпізнати %1$d рядків із CSV файлу + Закінчити + Додайте опис + Опис + Заплановано на + Додати гроші до + Оплатити з + З + Рахунок + ПО + Додати рахунок + Заголовок доходу + Заголовок витрати + Заголовок переказу + Витрата + Додати дату запланованого платежу + Заплатити + Отримати + Підтвердити видалення + Видалення цієї транзакції призведе до видалення її з історії транзакцій і відповідного оновлення балансу. + Підтвердити зміни в рахунку + Примітка: Ви намагаєтеся змінити обліковий запис, пов\'язаний з кредитом з рахунком в іншій валюті. \nВсі записи про кредит буде перераховано на основі поточних курсів валют + Підтвердити + Будь ласка, зачекайте, перераховуєм всі дані по кредиту + Створено + Привіт + Привіт, %1$s + Грошовий потік: %1$s%2$s %3$s + Пошук транзакції + Ivy Wallet має відкритий код + Ціль заощадження + Швидкий доступ + Налаштування + Імпортувати застарілі дані + Імпортуємо… + Мова + Курси валют + "НЕБЕЗПЕЧНА ЗОНА!!! Підключення Google Drive може пошкодити ваші дані! Робіть на свій страх і ризик!" + Монтувати диск + Змонтовано, створіть фіктивний файл + Додайте рамку Ivy + Видалити опубліковий запис + Виберіть свою мову + Встановити день початку місяця + Помилка: %1$s + Успіх!!! %1$s + Початковий день місяця %1$s + Світла тема + Темна тема + Системна тема + Заплановані\nплатежі + Поділись Ivy + Звіти + Кредити + Встановити валюту + Немає транзакцій + У вас немає транзакцій за вибраний період. + У вас немає жодної транзакції за %1$s.\nЩоб додати, натисніть кнопку \"+\" + Додати кредит + Немає кредитів + У вас немає жодного кредиту.\nЩоб додати, натисніть \"+ Додати кредит\". + Примітка: Видалення цього кредиту призведе до його остаточного видалення, включно з усіма записами, пов\'язаними з цим кредитом. + Будь ласка, зачекайте, перераховуються всіх записи по кредиту + Оплачено + %1$s %2$s залишилось + Відсотки по кредиту + %1$s %2$s оплачено + Додати запис + Відсотки + Немає записів + У вас немає жодних записів щодо цього кредиту. Щоб створити, натисніть \"Додати запис\". + Додати дохід + Додати витрату + Невизначений + %1$s\%% + Перекази + У вас немає транзакцій за %1$s.\nЩоб додати, натисніть кнопку \"Додати дохід\" або \"Додати витрату\", розташовану вище. + Примітка: Видалення цього облікового запису призведе до його остаточного видалення та видалення всіх пов\'язаних з ним транзакцій. + Примітка: Видалення цієї категорії призведе до її остаточного видалення. + Редагувати + транзакції + Головна + Додати запланований платіж + ДОДАТИ ДОХІД + ДОДАТИ ВИТРАТУ + ПЕРЕКАЗ + Пропустити + Додати нову + З %1$s + До %1$s + Діапазон + Конфіденційність і\nзбір даних + Проведіть пальцем, щоб прийняти наші умови використання + Погоджуюсь з умовами + Проведіть пальцем, щоб погодитися з нашою політикою конфіденційності + Погоджуюсь з політикою конфіденційності + Правила та умови + Політика конфіденційності + Відстежуйте свої доходи, витрати та бюджет за допомогою Ivy.\n\nІнтуїтивно зрозумілий інтерфейс, регулярні та заплановані платежі, керування кількома рахунками, упорядкування транзакцій за категоріями, змістовна статистика, експорт у CSV і багато іншого. + Введіть своє ім\'я,\nщоб персоналізувати свій\nгаманець + Як вас звати? + Ввести + Додати рахунки + Пропозиція + Далі + Додати категорії + Пропозиції + Встановити + Ваш персональний фінансовий менеджер + #opensource + Помилка. Спробуйте знову: %1$s + Вхід… + Успішно! + Увійти за допомогою Google + Офлайн акаунт + СИНХРОНІЗУЙТЕ СВОЇ ДАНІ З IVY CLOUD + Цілісність і захист даних не гарантуються! + АБО УВІЙТИ З ОФЛАЙН АКАУНТУ + Ваші дані будуть збережені локально (тільки на вашому телефоні) і не будуть синхронізовані з хмарою. Ви ризикуєте втратити їх, якщо видалите програму або зміните пристрій. Ви завжди можете активувати синхронізацію пізніше. + + Увійшовши, ви погоджуєтеся з %1$s та %2$s. + Завантажте CSV файл + з Ivy або іншого застосунку + Завантаження файлу резервної копії з іншого застосунку може тривати до 5 хвилин. Ви завжди можете імпортувати свої дані пізніше. + Завантажити файл резервної копії + Почати спочатку + Видалення запланованого платежу призведе до видалення всіх майбутніх несплачених або прострочених транзакцій, пов\'язаних із ним. + Встановити тип оплати + Запланувати початок на + ПОВТОРЮЄТЬСЯ КОЖНІ %1$d %2$s + видалено + \"ЗАПЛАНОВАНО НА \" + null + \"ПОЧИНАЄТЬСЯ З %1$s \" + Додати оплату + Одноразові платежі + Регулярні платежі + Немає планових платежів + У вас немає запланованих платежів.\nНатисніть кнопку \'⚡\' внизу, щоб додати. + Заплановані платежі + Сьогодні + Вчора + Завтра + Термін погашення %1$s + Майбутні + Прострочені + витрати + дохід + Редагувати рахунок + Новий рахунок + Назва рахунку + Включати рахунок + Введіть баланс рахунку + Виберіть валюту + Калькулятор + Розрахунок (+-/*=) + Редагувати категорію + Створити категорію + Назва категорії + Обрати категорію + Додати деталі + Очистити фільтр + Фільтр + Застосувати фільтр + За типом + Доходи + Період часу + Виберіть діапазон часу + Рахунки (%1$d) + Категорії (%1$d) + Очистити все + Вибрати все + Сума (необов\'язково) + Ключові слова (необов\'язково) + ВКЛЮЧАЄ + Додати ключове слово + ВИКЛЮЧЕННЯ + У вас немає транзакцій для вашого фільтра. + Без фільтра + Щоб створити звіт, спочатку встановіть фільтр. + Встановити фільтр + Експорт + У вас немає транзакцій по запиту: \"%1$s\". + + Резервне копіювання даних + Імпорт даних + Налаштування програми + Заблокувати програму + Показувати сповіщення + Приховати баланс + Натисніть на прихований баланс, щоб показати його на 5 секунд + Інші + Оцініть нас Google Play + Поділіться Ivy Wallet + Продукт + Небезпечна зона + Видалити всі дані користувача + Видалити всі дані користувача? + УВАГА! Ця дія НАЗАВЖДИ видалить всі дані для %1$s і ви не зможете їх відновити. + ваш обліковий запис + Підтвердити остаточне видалення для \'%1$s\' + всіх ваших даних + ОСТАННЕ ПОПЕРЕДЖЕННЯ! Після натискання \"Видалити\" ваші дані зникнуть назавжди. + Експорт даних + Будь ласка зачекайте, іде експорт даних + Початок місяця + Ivy Telegram + Центр допомоги + Roadmap + Запит функції + Зв\'язок з службою підтримки + Учасники проекту + ОБЛІКОВИЙ ЗАПИС + Вийти + Увійти + Синхронізація… + Дані синхронізовані з хмарою + Натисніть, щоб синхронізувати + Помилка синхронізації. Натисніть, щоб синхронізувати + Анонім + Експорт в CSV + Залишилось витратити + Бюджет перевищено на + Буфер перевищено на + Встановіть тип транзакції + Переказ + Вибраний + Пошук (USD, EUR, GBP, BTC і т.д.) + Попередньо обрана + Криптовалюта + Курс обміну + Виберіть колір + Змінити порядок + Ключове слово + Редагувати бюджет + Створити бюджет + Назва бюджету + СУМА БЮДЖЕТУ + Ви впевнені, що бажаєте видалити бюджет \"%1$s\"? + Редагувати ціль заощаджень + Виберіть значок + Виберіть місяць + або індивідуальний діапазон + Додати дату + або в останній + або за весь час + Скасувати \"За весь час\" + Вибрати \"За весь час\" + За весь час + Виберіть дату початку місяця + підтримує криптовалюти + Видалити + Зберегти + Додати + Створити + Редагувати кредит + Новий кредит + Назва кредиту + Пов\'язаний рахунок + Створити основну транзакцію + ВВЕДІТЬ СУМУ КРЕДИТУ + "Примітка: Ви намагаєтеся змінити рахунок, пов\'язаний з кредитом в іншій валюті. \nВсі кредитні записи будуть перераховані на основі поточного курсу валют " + Тип кредиту + Позичити гроші + Дати в борг гроші + Редагувати запис + Новий запис + Примітка + Позначити як \"Цікаво\" + Перерахувати суму за сьогоднішнім курсом обміну валют + ВВЕДІТЬ СУМУ ЗАПИСУ + Ви впевнені, що хочете видалити запис "%1$s"? + "Примітка: Ви намагаєтесь змінити рахунок, пов\'язаний із записом про кредит в іншій валюті.\nСума буде перерахована за поточним курсом валют " + Редагувати назву + Запланувати на + Один раз + Кілька разів + Починається з + Повторювати кожен + Надіслати + Що вам потрібно? + Поясніть це одним реченням. (підтримує markdown) + Останні 12 місяців + Останні 6 місяців + Останні 4 тижні + Останні 7 днів + Сьогодні, %1$s + Вчора, %1$s + Завтра, %1$s + Термін дії минув + Автентифікація пройшла успішно! + Помилка автентифікації. + Ви робили якісь транзакції сьогодні? 🏁 + Ви відслідковували свої витрати сьогодні? 💸 + Ви записали свої транзакції сьогодні? 🏁 + Готівка + Банк + Revolut + + + Транспорт + Продукти + Розваги + Покупки + Подарунки + Здоров\'я + Інвестиції + Автомобіль + Робота + Ресторан + Сім\'я + Соціальне життя + Доставка їжі + Подорожі + Фітнес + Саморозвиток + Одяг + Краса + Освіта + Домашні тварини + Спорт + Відрегулюйте початковий баланс + До рахунків + Натисніть на рахунок -> Натисніть на його баланс -> Введіть поточний баланс. Ось і все!]]> + Створіть свій перший запланований платіж + Автоматизуйте відстеження повторюваних транзакцій, таких як: підписки, орендна плата, зарплата тощо. Будьте попереду своїх фінансів, знаючи, скільки вам потрібно заплатити/отримати. + Ви знали? + Ivy Wallet має крутий віджет, який дозволяє додавати ДОХОДИ/ВИТРАТИ/ПЕРЕКАЗИ в один дотик зі стартового екрану вашого смартфона.\n\nПримітка: Якщо кнопка \"Додати віджет\" не працює, додайте його вручну з меню віджетів. + Додати віджет + Встановити бюджет + Ivy Wallet не тільки допомагає вам пасивно відстежувати свої витрати, але й активно створювати своє фінансове майбутнє, встановлюючи бюджети та дотримуючись їх. + Ви можете переглянути структуру своїх витрат за категоріями! Спробуйте, натисніть сіру/чорну кнопку \"Витрати\" прямо під вашим балансом. + Секторна кругова діаграма + Оцініть Ivy Wallet + Надішліть нам свій відгук! Допоможіть Ivy Wallet стати кращим і розвиватися, написавши нам відгук. Компліменти, ідеї та критика вітаються! Ми робимо все можливе.\n\nЗ найкращими побажаннями,\nКоманда Ivy + Допоможіть нам розвиватися, щоб ми могли інвестувати більше в розробку та робити додаток кращим для вас. Поділившись Ivy Wallet, ви порадуєте двох розробників, а також допоможете другові контролювати його фінанси. + Поділися з друзями + Ви можете створювати звіти, щоб отримати детальну інформацію про свої доходи та витрати. Фільтруйте свої транзакції за типом, періодом часу, категорією, рахунками, сумою, ключовими словами тощо, щоб отримати кращий огляд своїх фінансів. + Складіть звіт + Хочете зробити Ivy Wallet кращим? Напишіть нам відгук. Для нас це єдиний спосіб розробляти те, що ви хочете та потребуєте. Крім того, це допомагає нам займати вищі позиції в PlayStore, щоб ми могли витрачати гроші на продукт, а не на маркетинг.\n\nМи робимо все можливе.\nКоманда Ivy + Нам потрібна ваша допомога! + Ми звичайні дизайнер і розробник, які працюють над програмою після основної роботи. Зараз ми вкладаємо багато часу та грошей, а отримуємо лише витрати та виснаження. Якщо ви хочете, щоб ми продовжували позробку Ivy Wallet, поділіться ним із друзями та родиною.\n\nP.S. Відгуки Google PlayStore також дуже допомагають! + Ivy Wallet має відкритий код! + Ivy Wallet має відкритий код, і кожен може його побачити. Ми віримо, що прозорість і етика є обов\'язковими для кожного програмного продукту. Якщо вам подобається наша робота і ви хочете покращити програму, ви можете внести свій внесок у наш загальнодоступний репозиторій Github. + Внести свій внесок + Відрегулюйте баланс + Потрібна автентифікація + Доведіть, що у вас є доступ до цього пристрою, щоб розблокувати програму. + Загальний бюджет + Бюджет категорії + Багатокатегорійний (%1$s) бюджет + ПОЗИЧИЛИ + ДАЛИ В БОРГ + Січень + Лютий + Березень + Квітень + Травень + Червень + Липень + Серпень + Вересень + Жовтень + Листопад + Грудень + днів + день + тижні + тиждень + місяців + місяць + років + рік + + Розглядати перекази рахунків як дохід або витрату на екрані \"Рахунки\" + Дім + Створення звіту… + Сортувати за + Пропустити все + Підтвердити, пропустити все + Ви впевнені, що бажаєте пропустити всі прострочені планові платежі? + Перейти в автономний режим + УВАГА! Ця дія видалить всі ваші хмарні дані для %1$s НАЗАВЖДИ, офлайн-дані, збережені у вашій локальній програмі, залишаться. + Видалити всі дані, які зберігаються в хмарі? + Експериментальний + Експериментальні налаштування + Баланс гаманця + Позначити як підкатегорію + Батьківська категорія + *Це позначено як батьківську категорію + Розпакуйте всі підкатегорії + Перевірте всі \"Необов\'язкові стовпці\" в параметрах експорту + Перейменувати категорію переказу + Для користувачів, які не володіють англійською мовою, перейменувати системну категорію переказу на \"Переказ\" у файлі експорту + Застереження + Підкатегорії не підтримуються + Стовпці \"Подія\", \"Люди\" та \"Місце\" ігноруватимуться + \ No newline at end of file diff --git a/resources/src/main/res/values/colors.xml b/resources/src/main/res/values/colors.xml new file mode 100644 index 0000000..b9f64a3 --- /dev/null +++ b/resources/src/main/res/values/colors.xml @@ -0,0 +1,27 @@ + + + @color/ivy + @color/black + @color/black + + + #FFFAFAFA + #FF111114 + + #FF2B2C2D + #FF939199 + #FFEFEEF0 + + #6B4DFF + + #14CC9E + + #FFC44D + + #FF4D6B + #FF94A6 + + + + #00FFFFFF + diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml new file mode 100644 index 0000000..67b2e23 --- /dev/null +++ b/resources/src/main/res/values/strings.xml @@ -0,0 +1,466 @@ + + + Accounts + Total: %1$s %2$s + INCOME THIS MONTH + EXPENSES THIS MONTH + (excluded) + INCOME + EXPENSES + APP LOCKED + Authenticate to enter the app + Unlock + CURRENT BALANCE + BALANCE AFTER PLANNED PAYMENTS + Connect + Sync transactions + Syncing transactions… + Bank sync enabled: + Remove customer + Add budget + No budgets + You don\'t have any budgets set.\nTap the "+ Add budget" to add one. + Budgets + %1$s %2$s for categories + %1$s %2$s app budget + Budget info: %1$s / %2$s + Budget info: %1$s%2$s + Add category + Expenses + Expenses count + Income + Income count + Balance chart + BALANCE %1$s + Charts + Period: + Categories + Export CSV file + Export CSV file with standard options + Please use the standard options and make sure to include headers. + How to import + open + Steps + How to + Video + Article + Upload CSV file + Export Data + Upload CSV/ZIP file + Export to file + Character set: UTF-8\nDecimal separator: Decimal point \'.\'\nDelimiter character: Comma \',\' + Export Excel file + Convert XLS to CSV + !NOTE: If the exported file doesn\'t have ".xls" extension, add it by renaming the file manually. + Online CSV converter FREE + Check your email\'s "Promotions" and "Spam" folders + Download the \"transactions_export…\" file attached to the email. + If you have more than one currency you\'ll have to download each \"transactions_export…\" file and import it in Ivy. + Import from + Please wait + Importing the CSV file + Success + Failure + Imported + %1$d transactions + %1$d accounts + %1$d categories + Failed + %1$d rows from CSV file not recognized + Finish + Add description + Description + Planned for + Add money to + Pay with + From + Account + To + Add account + Income title + Expense title + Transfer title + Expense + Add planned date of payment + Pay + Get + Confirm deletion + Deleting this transaction will remove it from the transaction history and update the balance accordingly. + Confirm Account Change + Note: You are trying to change the account associated with the loan with an account of different currency, \nAll the loan records will be re-calculated based on today\'s exchanges rates + Confirm + Please wait, re-calculating all loan records + Created on + Hi + Hi %1$s + Cashflow: %1$s%2$s %3$s + Search transactions + Ivy Wallet is open-source + Savings goal + Quick access + Settings + Import old data + Importing… + Languages + Exchange rates + "DANGER ZONE!!! Mounting the Google Drive may corrupt your data! Do at your own risk!" + Mount drive + Mounted, create dummy file + Add Ivy frame + Nuke account\'s cache + Choose Your Language + Set start day of month + Error: %1$s + Success!!! %1$s + Start day of month %1$s + Light mode + Dark mode + Auto mode + Planned\nPayments + Share Ivy + Reports + Loans + Set currency + No transactions + You don\'t have any transactions for the selected period. + You don\'t have any transactions for %1$s.\nYou can add one by tapping the \"+\" button. + Add loan + No loans + You don\'t have any loans.\nTap the \"+ Add loan\" to add one. + Note: Deleting this loan will remove it permanently and delete all associated loan records with it. + Please wait, re-calculating all loan records + Paid + %1$s %2$s left + Loan Interest + %1$s %2$s paid + Add record + Interest + No records + You don\'t have any records for this loan. Tap "Add record" to create one. + Add income + Add expense + Unspecified + %1$s\%% + Account Transfers + You don\'t have any transactions for %1$s.\nYou can add one by scrolling down and tapping "Add income" or "Add expense" button at the top. + Note: Deleting this account will remove it permanently and delete all associated transactions with it. + Note: Deleting this category will remove it permanently. + Edit + transactions + Home + Add planned payment + ADD INCOME + ADD EXPENSE + ACCOUNT TRANSFER + Skip + Add new + From %1$s + To %1$s + Range + Privacy and\ndata collection + Swipe to agree with our Terms and conditions + Agreed with our Terms and conditions + Swipe to agree with our Privacy policy + Agreed with our Privacy policy + Terms and conditions + Privacy policy + Track your income, expenses and budget with Ivy.\n\nIntuitive UI, recurring and planned payments, manage multiple accounts, organize transactions in categories, meaningful statistics, export to CSV and so much more. + Enter your name\nto personalize your\nwallet + What\'s your name? + Enter + Add accounts + Suggestions + Next + Add categories + Suggestions + Set + Your personal money manager + #opensource + Error. Try again: %1$s + Signing in… + Success! + Login with Google + Offline account + SYNC YOUR DATA ON THE IVY CLOUD + Data integrity and protection aren\'t guaranteed! + OR ENTER WITH OFFLINE ACCOUNT + Your data will be saved locally (only on your phone) and won\'t be synced with the cloud. You risk losing it if you uninstall the app or change your device. You can always activate sync later if you decide to. + + By signing in, you agree with our %1$s and %2$s. + Import CSV file + from Ivy or another app + Importing a backup file from another can take up to 5 min. You can always import your data later if you want to. + Import backup file + Start fresh + Deleting this planned payment will delete all non-paid upcoming or overdue transactions associated with it. + Set payment type + Planned start at + REPEATS EVERY %1$d %2$s + deleted + "PLANNED FOR " + null + "STARTS %1$s " + Add payment + One time payments + Recurring payments + No planned payments + You don\'t have any planned payments.\nPress the \'⚡\' button at the bottom to add one. + Planned payments + Today + Yesterday + Tomorrow + Due on %1$s + Upcoming + Overdue + expenses + income + Edit account + New account + Account name + Include account + Enter account balance + Choose currency + Calculator + Calculation (+-/*=) + Edit category + Create category + Category name + Choose category + Enter any details here + Clear filter + Filter + Apply filter + By Type + Incomes + Time Period + Select time range + Accounts (%1$d) + Categories (%1$d) + Clear all + Select all + Amount (optional) + Keywords (optional) + INCLUDES + Add a keyword + EXCLUDES + You don\'t have any transactions for your filter. + No Filter + To generate a report you must first set a valid filter. + Set Filter + Export + You don\'t have any transactions for "%1$s" query. + + Backup Data + Import Data + App Settings + Lock app + Show notifications + Hide balance + Click on the hidden balance to show the balance for 5s + Other + Rate us on Google Play + Share Ivy Wallet + Product + Danger zone + Delete all user data + Delete all user data? + WARNING! This action will delete all data for %1$s PERMANENTLY and you won\'t be able to recover it. + your account + Confirm permanent deletion for \'%1$s\' + all of your data + FINAL WARNING! After clicking "Delete" your data will be gone forever. + Exporting Data + Please wait, exporting data + Start date of month + Ivy Telegram + Help Center + Roadmap + Request a feature + Contact support + Project Contributors + ACCOUNT + Logout + Login + Syncing… + Data synced to cloud + Tap to sync + Sync failed. Tap to sync + Anonymous + Export to CSV + Left to spend + Budget exceeded by + Buffer exceeded by + Set transaction type + Transfer + Selected + Search (USD, EUR, GBP, BTC, etc) + Pre-selected + Crypto + Exchange Rate + Choose color + Reorder + Keyword + Edit budget + Create budget + Budget name + BUDGET AMOUNT + Are you sure that you want to delete "%1$s" budget? + Edit Savings goal + Choose icon + Choose month + or custom range + Add date + or in the last + or all time + Unselect All-time + Select All-time + All-time + Choose start date of month + supports crypto + Delete + Save + Add + Create + Edit loan + New loan + Loan name + Associated Account + Create a Main Transaction + ENTER LOAN AMOUNT + "Note: You are trying to change the account associated with the loan with an account of different currency, \nAll the loan records will be re-calculated based on today's exchanges rates " + Loan type + Borrow money + Lend money + Edit record + New record + Note + Mark as Interest + Recalculate Amount with today\'s Currency exchange Rates + ENTER RECORD AMOUNT + Are you sure that you want to delete "%1$s" record? + "Note: You are trying to change the account associated with the loan record with an account of different currency\nThe amount will be re-calculated based on today's exchanges rates " + Edit name + Plan for + One time + Multiple times + Starts on + Repeats every + Submit + What do you need? + Explain it in one sentence. (supports markdown) + Last 12 months + Last 6 months + Last 4 weeks + Last 7 days + Today, %1$s + Yesterday, %1$s + Tomorrow, %1$s + Expired + Authentication succeeded! + Authentication failed. + Have you made any transactions today? 🏁 + Did you track your expenses today? 💸 + Have you recorded your transactions today? 🏁 + Cash + Bank + Revolut + + + Transport + Groceries + Entertainment + Shopping + Gifts + Health + Investments + Car + Work + Restaurant + Family + Social Life + Order food + Travel + Fitness + Self-development + Clothes + Beauty + Education + Pet + Sports + Adjust your initial balance + To accounts + Tap an account -> Tap its balance -> Enter current balance. That\'s it!]]> + Create your first planned payment + Automate the tracking of recurring transactions like your subscriptions, rent, salary, etc. Stay ahead of your finances by knowing how much you have to pay/get in advance. + Did you know? + Ivy Wallet has a cool widget that lets you add INCOME/EXPENSES/TRANSFER transactions with 1-click from your home\n\nNote: If the "Add widget" button doesn\'t work, please add it manually from your launcher\'s widgets menu. + Add widget + Set a budget + Ivy Wallet not only helps you to passively track your expenses but also proactively create your financial future by setting budgets and sticking to them. + You can see your expenses structure by categories! Try it, tap the gray/black Expenses button just below your balance. + Expenses PieChart + Review Ivy Wallet + Give us your feedback! Help Ivy Wallet become better and grow by writing us a review. Compliments, ideas, and critics are all welcome! We do our best.\n\nCheers,\nIvy Team + Help us grow so we can invest more in development and make the app better for you. By sharing Ivy Wallet you\'ll make two developers happy and also help a friend to take control of their finances. + Share with friends + You can generate reports to get deep insights about your income and spending. Filter your transactions by type, time period, category, accounts, amount, keywords and more to gain better view on your finances. + Make a report + Want to make Ivy Wallet better? Write us a review. That\'s the only way for us to develop what you want and need. Also it help us rank higher in the PlayStore so we can spend money on the product rather than marketing.\n\nWe do our best.\nIvy Team + We need your help! + We\'re just a designer and a developer working on the app after our 9–5 jobs. Currently, we invest a lot of time and money to generate only losses and exhaustion. If you want us to keep developing Ivy Wallet please share it with friends and family.\n\nP.S. Google PlayStore reviews also helps a lot! + Ivy Wallet is open-source! + Ivy Wallet\'s code is open and everyone can see it. We believe that transparency and ethics are must for every software product. If you like our work and want to make the app better you can contribute in our public Github repository. + Contribute + Adjust balance + Authentication required + Prove that you have access to this device to unlock the app. + Total Budget + Category Budget + Multi-Category (%1$s) Budget + BORROWED + LENT + January + February + March + April + May + June + July + August + September + October + November + December + days + day + weeks + week + months + month + years + year + + Treats account transfers as income or expense in Accounts Screen + Home + Generating report… + Sort by + Skip all + Confirm skip all + Are you sure that you want to skip all overdue planned payments? + Switch to offline mode + WARNING! This action will delete all your cloud-stored data for %1$s PERMANENTLY, the offline data stored in your local app will remain. + Delete all cloud-stored data? + Experimental + Experimental Settings + Wallet balance + Mark as Sub-Category + Parent Category + *This is marked as a Parent Category + Unpack All Subcategories + Check all \"Optional columns\" in export options + Rename transfer category + For non-English users, rename transfer system category to \"Transfer\" in export file + Caveats + Subcategories are not supported + Event, People and Place columns will be ignored + diff --git a/resources/src/main/res/values/styles.xml b/resources/src/main/res/values/styles.xml new file mode 100644 index 0000000..7dd3b59 --- /dev/null +++ b/resources/src/main/res/values/styles.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..9db1ebe --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,7 @@ +# Scripts + +Helpful scripts used in the Ivy Wallet project. + +- `runhaskell create_module.hs` creates a new module using Ivy Wallet's module template. +- `./unit_test` runs unit tests in all modules. +- `./integration_test` runs integration tests in all modules. \ No newline at end of file diff --git a/scripts/create_module.hs b/scripts/create_module.hs new file mode 100644 index 0000000..eeb63b3 --- /dev/null +++ b/scripts/create_module.hs @@ -0,0 +1,40 @@ +import System.Directory (createDirectoryIfMissing) +import System.FilePath.Posix (takeDirectory) + +main :: IO () +main = do + putStr "Enter module name: " + name <- getLine + createModule name + addToSettingsGradle name + putStrLn $ "Module " ++ name ++ " created." + +createModule :: String -> IO () +createModule name = do + let moduleRoot = name ++ "/" + createDirTree $ moduleRoot ++ "src/main/java/com/ivy/" ++ name ++ "/" + writeFile (name ++ "/src/main/AndroidManifest.xml") (manifest name) + + buildGradle <- readBuildGradle + writeFile (moduleRoot ++ "build.gradle.kts") buildGradle + + writeFile (moduleRoot ++ ".gitignore") gitIgnore + +addToSettingsGradle :: String -> IO () +addToSettingsGradle name = appendFile "settings.gradle.kts" (includeStm name) + where + includeStm :: String -> String + includeStm name = "\ninclude(\":" ++ name ++ "\")" + +gitIgnore :: String +gitIgnore = "/build" + +manifest :: String -> String +manifest name = "\n" + +readBuildGradle :: IO String +readBuildGradle = readFile "scripts/templates/build.gradle.kts.template" + +createDirTree :: FilePath -> IO () +createDirTree path = do + createDirectoryIfMissing True $ takeDirectory path diff --git a/scripts/integration_test.sh b/scripts/integration_test.sh new file mode 100755 index 0000000..639fd53 --- /dev/null +++ b/scripts/integration_test.sh @@ -0,0 +1 @@ +./gradlew connectedDebugAndroidTest -x :app:connectedDebugAndroidTest diff --git a/scripts/templates/build.gradle.kts.template b/scripts/templates/build.gradle.kts.template new file mode 100644 index 0000000..1229a44 --- /dev/null +++ b/scripts/templates/build.gradle.kts.template @@ -0,0 +1,14 @@ +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + Testing() +} \ No newline at end of file diff --git a/scripts/unit_test.sh b/scripts/unit_test.sh new file mode 100755 index 0000000..436b07e --- /dev/null +++ b/scripts/unit_test.sh @@ -0,0 +1 @@ +./gradlew testDebugUnitTest diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..93e365d --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,52 @@ +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven(url = "https://jitpack.io") + } +} +rootProject.name = "Ivy Wallet" +include(":app") +include(":common:main") +include(":common:android-test") +include(":common:test") +include(":design-system") +include(":main:bottom-bar") +include("main:accounts") +include("main:home") +include("main:more-menu") +include("main:customer-journey") +include(":categories") +include(":transaction") +include(":onboarding") +include(":main:impl") +include(":widgets") +include(":app-locked") +include(":android:common") +include(":android:billing") +include(":android:notifications") +include(":android:file-system") +include(":core:data-model") +include(":core:domain") +include(":core:exchange-provider") +include(":core:ui") +include(":core:persistence") +include(":network") +include(":resources") +include(":navigation") +include(":debug") +include(":formula:domain") +include(":formula:persistence") +include(":formula:ui") +include(":parser") +include(":math") +include(":drive:google-drive") +include(":settings") +include(":backup:base") +include(":backup:old") +include(":backup:impl") +include(":backup:api") +include(":photo-frame") +include(":sync") +include(":exchange-rates") diff --git a/settings/.gitignore b/settings/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/settings/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/settings/README.md b/settings/README.md new file mode 100644 index 0000000..3146580 --- /dev/null +++ b/settings/README.md @@ -0,0 +1,19 @@ +# Settings + +Ivy Wallet's "Settings" screen. + +## 🚧 Module under construction... + +If it hardly works, it's filled with bad code and anti-patterns anyway... + +### To see how a proper should look like refer to: + +- **[:core](../core)**: responsible for Ivy Wallet's domain +- **[:home](../home/)**: Ivy wallet's home screen. + +Apologies for the inconvenience! I'm a solo dev + the help of our awesome Ivy contributors. If you +want to support us: + +1. Star our repo. + [![GitHub Repo stars](https://img.shields.io/github/stars/Ivy-Apps/ivy-wallet?style=social)](https://github.com/Ivy-Apps/ivy-wallet/stargazers) +2. Have a look at our [Contributors Guide](../CONTRIBUTING.md). \ No newline at end of file diff --git a/settings/build.gradle.kts b/settings/build.gradle.kts new file mode 100644 index 0000000..6cb7320 --- /dev/null +++ b/settings/build.gradle.kts @@ -0,0 +1,28 @@ +import com.ivy.buildsrc.AppCompat +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":design-system")) + implementation(project(":core:domain")) + implementation(project(":core:ui")) + implementation(project(":core:data-model")) + implementation(project(":core:persistence")) + implementation(project(":navigation")) + implementation(project(":backup:old")) + implementation(project(":drive:google-drive")) + implementation(project(":backup:impl")) + AppCompat(false) + Testing() + + +} \ No newline at end of file diff --git a/settings/src/main/AndroidManifest.xml b/settings/src/main/AndroidManifest.xml new file mode 100644 index 0000000..fefb7f9 --- /dev/null +++ b/settings/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/settings/src/main/java/com/ivy/settings/SettingsEvent.kt b/settings/src/main/java/com/ivy/settings/SettingsEvent.kt new file mode 100644 index 0000000..79ba0b2 --- /dev/null +++ b/settings/src/main/java/com/ivy/settings/SettingsEvent.kt @@ -0,0 +1,24 @@ +package com.ivy.settings + +import android.net.Uri + +sealed interface SettingsEvent { + object Back : SettingsEvent + data class BaseCurrencyChange(val newCurrency: String) : SettingsEvent + data class StartDayOfMonth(val startDayOfMonth: Int) : SettingsEvent + data class LanguageChange(val languageCode: String) : SettingsEvent + + data class HideBalance(val hideBalance: Boolean) : SettingsEvent + data class AppLocked(val appLocked: Boolean) : SettingsEvent + + object ImportOldData : SettingsEvent + + object MountDrive : SettingsEvent + + object AddFrame : SettingsEvent + object NukeAccCache : SettingsEvent + object ExchangeRates : SettingsEvent + + data class BackupData(val backupLocation: Uri) : SettingsEvent +} + diff --git a/settings/src/main/java/com/ivy/settings/SettingsScreen.kt b/settings/src/main/java/com/ivy/settings/SettingsScreen.kt new file mode 100644 index 0000000..af87b7a --- /dev/null +++ b/settings/src/main/java/com/ivy/settings/SettingsScreen.kt @@ -0,0 +1,348 @@ +package com.ivy.settings + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ivy.core.ui.currency.CurrencyPickerModal +import com.ivy.core.ui.rootScreen +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.Green +import com.ivy.design.l0_system.color.Orange +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l1_buildingBlocks.H1 +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.SwitchRow +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.BackButton +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview +import com.ivy.settings.data.BackupImportState +import com.ivy.settings.data.Language +import java.time.Instant + +/* +- Dark Mode +- Hidden transactions (leads to new screen which you'll create later) +- Create Transaction steps (leads to new screen) +- Export backup +- Export CSV +- Import backup +- Exchange Rates (new screen) +- T&C + Privacy Policy +- Rate us +- Share Ivy Wallet +- Donate +- Ivy Telegram +- Delete all data +- GitHub repo + */ + +// H1, B1, Caption {H1Second, B1Second, ...} +// Focused, Medium +// IvyButton +// UI.color, UI.typo(fonts) +// SwitchRow (on/off) + +@Composable +fun BoxScope.SettingsScreen() { + val viewModel: SettingsViewModel = hiltViewModel() + val state by viewModel.uiState.collectAsState() + UI(state = state, onEvent = viewModel::onEvent) +} + +@Composable +private fun BoxScope.UI( + state: SettingsState, + onEvent: (SettingsEvent) -> Unit +) { + val currencyModal = rememberIvyModal() + val startDayOfMonthModal = rememberIvyModal() + val languagePickerModal = rememberIvyModal() + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + ) { + item(key = "content") { + BackButton(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + onEvent(SettingsEvent.Back) + } + + H1( + text = stringResource(R.string.settings), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + SpacerVer(height = 12.dp) + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, visibility = Visibility.Medium, + feeling = Feeling.Positive, text = state.baseCurrency, icon = null + ) { + currencyModal.show() + } + SpacerVer(height = 12.dp) + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, visibility = Visibility.Focused, + feeling = Feeling.Positive, text = String.format( + stringResource(R.string.start_day_of_month), + state.startDayOfMonth + ), + icon = null + ) { + startDayOfMonthModal.show() + } + SpacerVer(height = 12.dp) + SwitchRow( + enabled = state.hideBalance, + text = stringResource(R.string.hide_balance), + onValueChange = { + onEvent(SettingsEvent.HideBalance(hideBalance = it)) + }) + SpacerVer(height = 12.dp) + SwitchRow( + enabled = state.appLocked, + text = stringResource(R.string.lock_app), + onValueChange = { + onEvent(SettingsEvent.AppLocked(appLocked = it)) + }) + SpacerVer(height = 12.dp) + + val rootScreen = rootScreen() + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, visibility = Visibility.High, + feeling = when (state.importOldData) { + is BackupImportState.Error -> Feeling.Negative + BackupImportState.Idle -> Feeling.Positive + BackupImportState.Importing -> Feeling.Custom(Orange) + is BackupImportState.Success -> Feeling.Custom(Green) + }, + text = when (state.importOldData) { + BackupImportState.Idle -> stringResource(R.string.import_old_data) + BackupImportState.Importing -> stringResource(R.string.import_old_data_in_progress) + is BackupImportState.Error -> String.format( + stringResource(R.string.import_data_error), + state.importOldData.message + ) + is BackupImportState.Success -> String.format( + stringResource(R.string.import_data_successfully), + state.importOldData.message + ) + }, + icon = null + ) { + onEvent(SettingsEvent.ImportOldData) + } + SpacerVer(height = 12.dp) + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, visibility = Visibility.Medium, + feeling = Feeling.Positive, text = stringResource(R.string.languages), + icon = null + ) { + languagePickerModal.show() + } + SpacerVer(height = 12.dp) + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, visibility = Visibility.Medium, + feeling = Feeling.Positive, text = stringResource(R.string.exchange_rates), + icon = null + ) { + onEvent(SettingsEvent.ExchangeRates) + } + } + item { + SpacerVer(height = 24.dp) + B2( + modifier = Modifier.padding(horizontal = 16.dp), + text = stringResource(R.string.data_risk_warning), + color = UI.colors.red + ) + SpacerVer(height = 12.dp) + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Negative, + text = if (state.driveMounted) + stringResource(R.string.drive_mounted) else stringResource(R.string.mount_drive), + icon = null + ) { + onEvent(SettingsEvent.MountDrive) + } + } + item { + SpacerVer(height = 24.dp) + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = stringResource(R.string.add_ivy_frame), + icon = null + ) { + onEvent(SettingsEvent.AddFrame) + } + } + item { + SpacerVer(height = 16.dp) + val rootScreen = rootScreen() + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = stringResource(R.string.backup_data), + icon = null + ) { + rootScreen.createFile( + fileName = "Ivy-Wallet-Backup-${Instant.now().epochSecond}.zip" + ) { + onEvent(SettingsEvent.BackupData(backupLocation = it)) + } + } + } + item { + SpacerVer(height = 24.dp) + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Negative, + text = stringResource(R.string.nuke_accounts_cache), + icon = null + ) { + onEvent(SettingsEvent.NukeAccCache) + } + } + } + + CurrencyPickerModal( + modal = currencyModal, + initialCurrency = state.baseCurrency, + onCurrencyPick = { newCurrency -> + onEvent(SettingsEvent.BaseCurrencyChange(newCurrency = newCurrency)) + } + ) + + StartDayOfMonthModal( + modal = startDayOfMonthModal, + onStartDayOfMonthChange = { startDayOfMonth -> + onEvent(SettingsEvent.StartDayOfMonth(startDayOfMonth = startDayOfMonth)) + }) + LanguagePickerModal( + modal = languagePickerModal, + supportedLanguages = state.supportedLanguages, + currentLanguageCode = state.currentLanguage, + onLanguageChange = { languageCode -> + onEvent(SettingsEvent.LanguageChange(languageCode)) + } + ) + +} + +@Composable +private fun BoxScope.StartDayOfMonthModal( + modal: IvyModal, + onStartDayOfMonthChange: (Int) -> Unit, +) { + Modal( + modal = modal, + actions = { + } + ) { + Title(text = stringResource(R.string.set_day_of_month)) + SpacerVer(height = 24.dp) + LazyColumn { + items(items = (1..31).toList()) { number -> + SpacerVer(height = 12.dp) + IvyButton( + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = number.toString(), + icon = null + ) { + onStartDayOfMonthChange(number) + modal.hide() + } + } + } + SpacerVer(height = 24.dp) + } +} + +//TODO:have to highlight the selected language +@Composable +private fun BoxScope.LanguagePickerModal( + modal: IvyModal, + onLanguageChange: (String) -> Unit, + supportedLanguages: List, + currentLanguageCode: String +) { + Modal( + modal = modal, + actions = { + } + ) { + Title(text = stringResource(R.string.choose_language)) + SpacerVer(height = 24.dp) + LazyColumn { + items(supportedLanguages) { language -> + SpacerVer(height = 12.dp) + IvyButton( + size = ButtonSize.Big, + visibility = if (currentLanguageCode == language.languageCode) Visibility.Focused else Visibility.Medium, + feeling = Feeling.Positive, + text = language.name, + icon = null + ) { + onLanguageChange(language.languageCode) + modal.hide() + } + } + } + SpacerVer(height = 24.dp) + } +} + + +@Preview +@Composable +private fun Preview() { + IvyPreview { + UI( + state = SettingsState( + baseCurrency = "BGN", + startDayOfMonth = 1, + hideBalance = false, + appLocked = false, + driveMounted = false, + importOldData = BackupImportState.Idle, + supportedLanguages = emptyList(), + currentLanguage = "" + ), + onEvent = {} + ) + } +} \ No newline at end of file diff --git a/settings/src/main/java/com/ivy/settings/SettingsState.kt b/settings/src/main/java/com/ivy/settings/SettingsState.kt new file mode 100644 index 0000000..c4add94 --- /dev/null +++ b/settings/src/main/java/com/ivy/settings/SettingsState.kt @@ -0,0 +1,15 @@ +package com.ivy.settings + +import com.ivy.settings.data.BackupImportState +import com.ivy.settings.data.Language + +data class SettingsState( + val baseCurrency: String, + val startDayOfMonth: Int, + val hideBalance: Boolean, + val appLocked: Boolean, + val driveMounted: Boolean, + val importOldData: BackupImportState, + val supportedLanguages: List, + val currentLanguage: String +) \ No newline at end of file diff --git a/settings/src/main/java/com/ivy/settings/SettingsViewModel.kt b/settings/src/main/java/com/ivy/settings/SettingsViewModel.kt new file mode 100644 index 0000000..be6ed69 --- /dev/null +++ b/settings/src/main/java/com/ivy/settings/SettingsViewModel.kt @@ -0,0 +1,193 @@ +package com.ivy.settings + +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import androidx.lifecycle.viewModelScope +import arrow.core.Either.Left +import arrow.core.Either.Right +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.action.settings.applocked.AppLockedFlow +import com.ivy.core.domain.action.settings.applocked.WriteAppLockedAct +import com.ivy.core.domain.action.settings.balance.HideBalanceFlow +import com.ivy.core.domain.action.settings.balance.WriteHideBalanceAct +import com.ivy.core.domain.action.settings.basecurrency.BaseCurrencyFlow +import com.ivy.core.domain.action.settings.basecurrency.WriteBaseCurrencyAct +import com.ivy.core.domain.action.settings.startdayofmonth.StartDayOfMonthFlow +import com.ivy.core.domain.action.settings.startdayofmonth.WriteStartDayOfMonthAct +import com.ivy.core.domain.algorithm.accountcache.NukeAccountCacheAct +import com.ivy.core.domain.pure.util.combine +import com.ivy.core.ui.Toaster +import com.ivy.drive.google_drive.api.GoogleDriveConnection +import com.ivy.drive.google_drive.api.GoogleDriveService +import com.ivy.impl.export.BackupDataAct +import com.ivy.navigation.Navigator +import com.ivy.navigation.destinations.Destination +import com.ivy.settings.data.BackupImportState +import com.ivy.settings.data.Language +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.time.LocalDateTime +import javax.inject.Inject +import kotlin.io.path.Path + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val navigator: Navigator, + private val baseCurrencyFlow: BaseCurrencyFlow, + private val writeBaseCurrencyAct: WriteBaseCurrencyAct, + private val startDayOfMonthFlow: StartDayOfMonthFlow, + private val writeStartDayOfMonthAct: WriteStartDayOfMonthAct, + private val hideBalanceFlow: HideBalanceFlow, + private val writeHideBalanceAct: WriteHideBalanceAct, + private val appLockedFlow: AppLockedFlow, + private val writeAppLockedAct: WriteAppLockedAct, + private val googleDriveConnection: GoogleDriveConnection, + private val googleDriveService: GoogleDriveService, + private val nukeAccountCacheAct: NukeAccountCacheAct, + private val backupDataAct: BackupDataAct, + private val toaster: Toaster, +) : SimpleFlowViewModel() { + override val initialUi: SettingsState = SettingsState( + baseCurrency = "", + startDayOfMonth = 1, + hideBalance = false, + appLocked = false, + driveMounted = false, + importOldData = BackupImportState.Idle, + supportedLanguages = enumValues().toList(), + currentLanguage = AppCompatDelegate.getApplicationLocales()[0].toString() + ) + + private val importOldDataState = MutableStateFlow(initialUi.importOldData) + + private val currentLanguageState = MutableStateFlow(initialUi.currentLanguage) + + override val uiFlow: Flow = combine( + baseCurrencyFlow(), + startDayOfMonthFlow(), + hideBalanceFlow(Unit), + appLockedFlow(Unit), + googleDriveConnection.driveMounted, + importOldDataState, + currentLanguageState + ) { baseCurrency, startDayOfMonth, hideBalance, + appLocked, driveMounted, importOldData, currentLanguage -> + SettingsState( + baseCurrency = baseCurrency, + startDayOfMonth = startDayOfMonth, + hideBalance = hideBalance, + appLocked = appLocked, + driveMounted = driveMounted, + importOldData = importOldData, + supportedLanguages = initialUi.supportedLanguages, + currentLanguage = currentLanguage + ) + } + + override suspend fun handleEvent(event: SettingsEvent) { + when (event) { + SettingsEvent.Back -> navigator.back() + is SettingsEvent.BaseCurrencyChange -> { + writeBaseCurrencyAct(event.newCurrency) + } + + is SettingsEvent.StartDayOfMonth -> { + writeStartDayOfMonthAct(event.startDayOfMonth) + } + +// changing locale to the selected language {will fallback to default strings.xml file if language is not supported} + is SettingsEvent.LanguageChange -> { + AppCompatDelegate.setApplicationLocales( + LocaleListCompat.forLanguageTags(event.languageCode) + ) + currentLanguageState.value = event.languageCode + } + + is SettingsEvent.ExchangeRates -> { + handleExchangeRatesEvent() + } + + is SettingsEvent.HideBalance -> { + writeHideBalanceAct(event.hideBalance) + } + + is SettingsEvent.AppLocked -> { + writeAppLockedAct(event.appLocked) + } + + SettingsEvent.ImportOldData -> handleImportOldData() + is SettingsEvent.MountDrive -> handleMountDrive() + SettingsEvent.AddFrame -> handleAddFrame() + SettingsEvent.NukeAccCache -> { + nukeAccountCacheAct(Unit) + } + + is SettingsEvent.BackupData -> handleBackupData(event) + } + } + + private suspend fun handleImportOldData() { + navigator.navigate(Destination.importBackup.destination(Unit)) + } + + private fun handleExchangeRatesEvent() { + navigator.navigate(Destination.exchangeRates.destination(Unit)) + } + + private suspend fun handleMountDrive() { + if (googleDriveConnection.driveMounted.value) { + withContext(Dispatchers.IO) { + val result = googleDriveService.write( + path = Path("Ivy-Wallet-Debug-sync-folder/Backup/test.txt"), + content = LocalDateTime.now().toString() + ) + when (result) { + is Left -> Timber.d( + "Error writing to drive: ${ + result.value.exception.also { + it.printStackTrace() + }.message + }" + ) + + is Right -> Timber.d("Successfully wrote to drive") + } + } + } else { + googleDriveConnection.connect() + } + } + + private fun handleAddFrame() { + navigator.navigate(Destination.addFrame.destination(Unit)) + } + + + var backupInProgress = false + private suspend fun handleBackupData(event: SettingsEvent.BackupData) { + if (backupInProgress) return + viewModelScope.launch { + backupInProgress = true + when (val result = backupDataAct(BackupDataAct.Input(event.backupLocation))) { + is Left -> { + result.value.reason?.printStackTrace() + toaster.show("Error: ${result.value.reason}") + } + + is Right -> { + if (result.value.uploadedToDrive) { + toaster.show("Success! Data uploaded to your Google Drive.") + } else { + toaster.show("Local backup successful!") + } + } + } + backupInProgress = false + } + } +} \ No newline at end of file diff --git a/settings/src/main/java/com/ivy/settings/data/BackupImportState.kt b/settings/src/main/java/com/ivy/settings/data/BackupImportState.kt new file mode 100644 index 0000000..a0180fc --- /dev/null +++ b/settings/src/main/java/com/ivy/settings/data/BackupImportState.kt @@ -0,0 +1,8 @@ +package com.ivy.settings.data + +sealed interface BackupImportState { + object Idle : BackupImportState + object Importing : BackupImportState + data class Success(val message: String) : BackupImportState + data class Error(val message: String) : BackupImportState +} \ No newline at end of file diff --git a/settings/src/main/java/com/ivy/settings/data/Ivy_languages.kt b/settings/src/main/java/com/ivy/settings/data/Ivy_languages.kt new file mode 100644 index 0000000..98ef57c --- /dev/null +++ b/settings/src/main/java/com/ivy/settings/data/Ivy_languages.kt @@ -0,0 +1,17 @@ +package com.ivy.settings.data + +// Ivy Wallet's Supported list of languages +enum class Language(val languageCode: String) { + English("en"), + Spanish("es"), + Arabic("ar"), + Bulgarian("bg"), + Hindi("hi"), + Italian("it"), + Kannada("kn"), + Polish("pl"), + Portuguese("pt"), + Russian("ru"), + Tamil("ta"), + Ukrainian("uk") +} diff --git a/sync/.gitignore b/sync/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/sync/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/sync/build.gradle.kts b/sync/build.gradle.kts new file mode 100644 index 0000000..7e817d2 --- /dev/null +++ b/sync/build.gradle.kts @@ -0,0 +1,17 @@ +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":core:domain")) + implementation(project(":backup:base")) + Testing() +} \ No newline at end of file diff --git a/sync/src/main/AndroidManifest.xml b/sync/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e32ca00 --- /dev/null +++ b/sync/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/sync/src/main/java/com/ivy/sync/action/RemoteBackupSource.kt b/sync/src/main/java/com/ivy/sync/action/RemoteBackupSource.kt new file mode 100644 index 0000000..0dc499b --- /dev/null +++ b/sync/src/main/java/com/ivy/sync/action/RemoteBackupSource.kt @@ -0,0 +1,11 @@ +package com.ivy.sync.action + +import arrow.core.Either +import com.ivy.core.data.sync.IvyWalletData +import com.ivy.core.domain.api.data.ActionError + +interface RemoteBackupSource { + suspend fun fetchBackup(): Either + + suspend fun uploadBackup(updated: IvyWalletData): Either +} \ No newline at end of file diff --git a/sync/src/main/java/com/ivy/sync/action/RemoteBackupSourceImpl.kt b/sync/src/main/java/com/ivy/sync/action/RemoteBackupSourceImpl.kt new file mode 100644 index 0000000..f201bfc --- /dev/null +++ b/sync/src/main/java/com/ivy/sync/action/RemoteBackupSourceImpl.kt @@ -0,0 +1,18 @@ +package com.ivy.sync.action + +import arrow.core.Either +import com.ivy.core.data.sync.IvyWalletData +import com.ivy.core.domain.api.data.ActionError +import javax.inject.Inject + +class RemoteBackupSourceImpl @Inject constructor( + +) : RemoteBackupSource { + override suspend fun fetchBackup(): Either { + TODO("Fetch Backup from Google Drive") + } + + override suspend fun uploadBackup(updated: IvyWalletData): Either { + TODO("Update Backup to Google Drive") + } +} \ No newline at end of file diff --git a/sync/src/main/java/com/ivy/sync/api/SyncAct.kt b/sync/src/main/java/com/ivy/sync/api/SyncAct.kt new file mode 100644 index 0000000..abc0f17 --- /dev/null +++ b/sync/src/main/java/com/ivy/sync/api/SyncAct.kt @@ -0,0 +1,47 @@ +package com.ivy.sync.api + +import arrow.core.Either +import arrow.core.continuations.either +import com.ivy.backup.base.WriteIvyWalletDataAct +import com.ivy.core.domain.action.Action +import com.ivy.core.domain.api.action.read.IvyWalletDataFromPartialAct +import com.ivy.core.domain.api.action.read.PartialIvyWalletDataAct +import com.ivy.core.domain.api.data.ActionError +import com.ivy.sync.action.RemoteBackupSource +import com.ivy.sync.calculation.applyDiff +import com.ivy.sync.calculation.calculateDiff +import javax.inject.Inject + +class SyncAct @Inject constructor( + private val remoteBackupSource: RemoteBackupSource, + private val partialIvyWalletDataAct: PartialIvyWalletDataAct, + private val ivyWalletDataFromPartialAct: IvyWalletDataFromPartialAct, + private val writeIvyWalletDataAct: WriteIvyWalletDataAct, +) : Action>() { + /** + * Eventual consistency: + * If any of the steps fail, nothing gets corrupted. + * On the next sync (if successful) remote and local should converge. + */ + override suspend fun action(input: Unit): Either = either { + // 1. Fetch the entire Backup JSON from Drive (action) + val completeRemoteBackup = remoteBackupSource.fetchBackup().bind() + // 2. Retrieve only id, removed, lastUpdated from Local DB (action) + val partialLocalDb = partialIvyWalletDataAct(Unit).bind() + // 3. Calculate diffs (calculation) + val diff = calculateDiff( + remote = completeRemoteBackup, + localPartial = partialLocalDb, + ) + + // 4. Retrieve complete local diff: SELECT *; not only their ids (action) + val completeLocalDiff = ivyWalletDataFromPartialAct(diff.remotePartial).bind() + // 5. Update the remote backup with the local items (calculation) + val updatedRemoteBackup = completeRemoteBackup.applyDiff(completeLocalDiff) + // 6. Upload the updated remote backup (action) + remoteBackupSource.uploadBackup(updatedRemoteBackup).bind() + + // 7. Update the Local DB with the remote diff (action) + writeIvyWalletDataAct(diff.local).bind() + } +} \ No newline at end of file diff --git a/sync/src/main/java/com/ivy/sync/calculation/AplyDiff.kt b/sync/src/main/java/com/ivy/sync/calculation/AplyDiff.kt new file mode 100644 index 0000000..4682830 --- /dev/null +++ b/sync/src/main/java/com/ivy/sync/calculation/AplyDiff.kt @@ -0,0 +1,37 @@ +package com.ivy.sync.calculation + +import com.ivy.core.data.sync.IvyWalletData +import com.ivy.core.data.sync.SyncData +import com.ivy.core.data.sync.Syncable + +internal fun IvyWalletData.applyDiff( + diff: IvyWalletData, +): IvyWalletData = IvyWalletData( + accounts = accounts.applyDiff(diff.accounts), + transactions = transactions.applyDiff(diff.transactions), + categories = categories.applyDiff(diff.categories), + tags = tags.applyDiff(diff.tags), + recurringRules = recurringRules.applyDiff(diff.recurringRules), + attachments = attachments.applyDiff(diff.attachments), + budgets = budgets.applyDiff(diff.budgets), + savingGoals = savingGoals.applyDiff(diff.savingGoals), + savingGoalRecords = savingGoalRecords.applyDiff(diff.savingGoalRecords) +) + +private fun SyncData.applyDiff( + diff: SyncData +): SyncData { + val sourceItems = items.associateBy(Syncable::id).toMutableMap() + + // Overwrite remote with local + diff.items.forEach { localItem -> + sourceItems[localItem.id] = localItem + } + + return SyncData( + items = sourceItems.values.toList(), + // The "deleted" set is just inflated + // TODO: We may introduce an operation to clear "tombstones" which are grow-only + deleted = deleted + diff.deleted + ) +} \ No newline at end of file diff --git a/sync/src/main/java/com/ivy/sync/calculation/SyncDiff.kt b/sync/src/main/java/com/ivy/sync/calculation/SyncDiff.kt new file mode 100644 index 0000000..182f2ec --- /dev/null +++ b/sync/src/main/java/com/ivy/sync/calculation/SyncDiff.kt @@ -0,0 +1,128 @@ +package com.ivy.sync.calculation + +import com.ivy.core.data.sync.* +import com.ivy.core.domain.calculation.syncDataFrom + +/** + * Calculates the diff that must be applied to sync [remote] and [localPartial]. + * This operation **prefers the [remote] version of history**. + * + * It has the following properties: + * - **Idempotency**: it can be applied multiple times w/o changing the result. + * - **Pure**: it has no side-effects. + * - **Eventual Consistency**: will converge at some point on the same version of [IvyWalletData] + * after apply multiple diffs on arbitrary [remote] and [localPartial] versions. + * + * @param remote a complete remote backup containing the entire Backup JSON for all items + * @param localPartial a dump of the Local DB containing only [Syncable] info: + * "id", "removed", and "last_updated + * @return a complete [IvyWalletData] diff to apply to the Local DB and + * an partial [IvyWalletData] ([Syncable] only) diff to apply to the Remote Backup + */ +internal fun calculateDiff( + remote: IvyWalletData, + localPartial: PartialIvyWalletData +): RemoteLocalDiff { + val accounts = itemDiff(remote.accounts, localPartial.accounts) + val transactions = itemDiff(remote.transactions, localPartial.transactions) + val categories = itemDiff(remote.categories, localPartial.categories) + val tags = itemDiff(remote.tags, localPartial.tags) + val recurringRules = itemDiff(remote.recurringRules, localPartial.recurringRules) + val attachments = itemDiff(remote.attachments, localPartial.attachments) + val budgets = itemDiff(remote.budgets, localPartial.budgets) + val savingGoals = itemDiff(remote.savingGoals, localPartial.savingGoals) + val savingGoalRecords = itemDiff(remote.savingGoalRecords, localPartial.savingGoalRecords) + + return RemoteLocalDiff( + local = IvyWalletData( + accounts = accounts.local, + transactions = transactions.local, + categories = categories.local, + tags = tags.local, + recurringRules = recurringRules.local, + attachments = attachments.local, + budgets = budgets.local, + savingGoals = savingGoals.local, + savingGoalRecords = savingGoalRecords.local, + ), + remotePartial = PartialIvyWalletData( + accounts = accounts.remotePartial, + transactions = transactions.remotePartial, + categories = categories.remotePartial, + tags = tags.remotePartial, + recurringRules = recurringRules.remotePartial, + attachments = attachments.remotePartial, + budgets = budgets.remotePartial, + savingGoals = savingGoals.remotePartial, + savingGoalRecords = savingGoalRecords.remotePartial + ) + ) +} + +// exposed for testing purposes +internal inline fun itemDiff( + remote: SyncData, + local: SyncData +): RemoteLocalItemDiff { + val remoteMap = combineItemsAndDeleted(remote) + val localMap = combineItemsAndDeleted(local) + + return RemoteLocalItemDiff( + local = syncDataFrom( + remoteMap.takeOnlyMissingOrNewer( + localMap, + // prefer remote on collision + CollisionResolution.TakeLeft + ) + ), + remotePartial = syncDataFrom( + localMap.takeOnlyMissingOrNewer( + remoteMap, + // prefer remote on collision + CollisionResolution.TakeRight + ) + ), + ) +} + +private fun Map.takeOnlyMissingOrNewer( + right: Map, + collision: CollisionResolution, +): List { + return values.filter { leftItem -> + val itemMissing = leftItem.id !in right + if (itemMissing) return@filter true // take left + + val rightItem = right[leftItem.id] ?: error("impossible, must be in right!") + when { + leftItem.lastUpdated > rightItem.lastUpdated -> true // take left + leftItem.lastUpdated < rightItem.lastUpdated -> false // take right + else -> { + // left.lastUpdated == rightItem.lastUpdated + when (collision) { + CollisionResolution.TakeLeft -> true// take left + CollisionResolution.TakeRight -> false // take right + } + } + } + } +} + +internal enum class CollisionResolution { + TakeLeft, TakeRight +} + +private fun combineItemsAndDeleted(data: SyncData): Map = + data.items.associateBy(Syncable::id) + + data.deleted.associateBy(Syncable::id) + + +internal data class RemoteLocalDiff( + val local: IvyWalletData, + val remotePartial: PartialIvyWalletData +) + +internal data class RemoteLocalItemDiff( + val local: SyncData, + val remotePartial: SyncData, +) \ No newline at end of file diff --git a/sync/src/main/java/com/ivy/sync/di/SyncModuleDI.kt b/sync/src/main/java/com/ivy/sync/di/SyncModuleDI.kt new file mode 100644 index 0000000..d86f8df --- /dev/null +++ b/sync/src/main/java/com/ivy/sync/di/SyncModuleDI.kt @@ -0,0 +1,16 @@ +package com.ivy.sync.di + +import com.ivy.sync.action.RemoteBackupSource +import com.ivy.sync.action.RemoteBackupSourceImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class SyncModuleDI { + + @Binds + abstract fun provideRemoteBackupSource(source: RemoteBackupSourceImpl): RemoteBackupSource +} \ No newline at end of file diff --git a/transaction/.gitignore b/transaction/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/transaction/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/transaction/README.md b/transaction/README.md new file mode 100644 index 0000000..0f6c490 --- /dev/null +++ b/transaction/README.md @@ -0,0 +1,9 @@ +# Transaction + +Key module responsible for transactions CRUD UI and the smooth create transaction semi-automated flow. + +**Screens:** +- `NewTransactionScreen`: creates a new Income or Expense transaction +- `EditTransactionScreen`: edit an Income or Expense transaction +- `NewTransferScreen`: creates a new Transfer between accounts via transactions batch +- `EditTransferScreen`: edit a Transfer between accounts \ No newline at end of file diff --git a/transaction/build.gradle.kts b/transaction/build.gradle.kts new file mode 100644 index 0000000..cd0bdd5 --- /dev/null +++ b/transaction/build.gradle.kts @@ -0,0 +1,20 @@ +import com.ivy.buildsrc.Hilt +import com.ivy.buildsrc.Testing + +apply() + +plugins { + `android-library` +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":design-system")) + implementation(project(":core:domain")) + implementation(project(":core:ui")) + implementation(project(":core:data-model")) + implementation(project(":core:persistence")) + implementation(project(":navigation")) + Testing() +} \ No newline at end of file diff --git a/transaction/src/main/AndroidManifest.xml b/transaction/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0a097ab --- /dev/null +++ b/transaction/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/action/TitleSuggestionsFlow.kt b/transaction/src/main/java/com/ivy/transaction/action/TitleSuggestionsFlow.kt new file mode 100644 index 0000000..44ef42f --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/action/TitleSuggestionsFlow.kt @@ -0,0 +1,50 @@ +package com.ivy.transaction.action + +import arrow.core.nonEmptyListOf +import com.ivy.common.toUUID +import com.ivy.core.domain.action.FlowAction +import com.ivy.core.domain.action.transaction.TrnQuery +import com.ivy.core.domain.action.transaction.TrnQuery.ByCategoryId +import com.ivy.core.domain.action.transaction.TrnsFlow +import com.ivy.core.domain.action.transaction.and +import com.ivy.core.ui.data.CategoryUi +import com.ivy.data.transaction.TrnPurpose +import com.ivy.transaction.pure.suggestTitle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class TitleSuggestionsFlow @Inject constructor( + private val trnsFlow: TrnsFlow, +) : FlowAction>() { + data class Input( + val title: String?, + val categoryUi: CategoryUi?, + val transfer: Boolean, + ) + + override fun createFlow(input: Input): Flow> = + trnsFlow(input.suggestionsQuery()).map { trns -> + suggestTitle( + transactions = trns, + title = input.title, + ) + } + + private fun Input.suggestionsQuery(): TrnQuery = + if (transfer) { + ByCategoryId( + categoryUi?.id?.toUUID() + ) and TrnQuery.ByPurposeIn( + nonEmptyListOf( + TrnPurpose.Fee, + TrnPurpose.TransferFrom, + TrnPurpose.TransferTo, + ) + ) + } else { + ByCategoryId( + categoryUi?.id?.toUUID() + ) + } +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/component/BaseBottomSheet.kt b/transaction/src/main/java/com/ivy/transaction/component/BaseBottomSheet.kt new file mode 100644 index 0000000..4146f5c --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/component/BaseBottomSheet.kt @@ -0,0 +1,115 @@ +package com.ivy.transaction.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.H1 +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerWeight +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.DeleteButton +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.keyboardShiftAnimated +import com.ivy.resources.R + +@Composable +fun BoxScope.BaseBottomSheet( + ctaText: String, + @DrawableRes + ctaIcon: Int, + modifier: Modifier = Modifier, + secondaryActions: (@Composable RowScope.() -> Unit)? = null, + onCtaClick: () -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + val keyboardShiftDp by keyboardShiftAnimated() + Column( + modifier = modifier + .align(Alignment.BottomCenter) + .border(1.dp, UI.colors.neutral, UI.shapes.roundedTop) + .background(UI.colors.pure, UI.shapes.roundedTop) + .padding(bottom = 8.dp, top = 12.dp) + .padding(bottom = keyboardShiftDp) + ) { + content() + BottomBar( + ctaText = ctaText, + ctaIcon = ctaIcon, + secondaryActions = secondaryActions, + onCtaClick = onCtaClick, + ) + } +} + +@Composable +private fun BottomBar( + modifier: Modifier = Modifier, + ctaText: String, + @DrawableRes + ctaIcon: Int, + secondaryActions: (@Composable RowScope.() -> Unit)?, + onCtaClick: () -> Unit +) { + val lineColor = UI.colors.medium + Row( + modifier = modifier + .fillMaxWidth() + .drawBehind { + val height = this.size.height + val width = this.size.width + + drawLine( + color = lineColor, + strokeWidth = 2.dp.toPx(), + start = Offset(x = 0f, y = height / 2), + end = Offset(x = width, y = height / 2) + ) + } + .padding(horizontal = 16.dp) + ) { + SpacerWeight(weight = 1f) + secondaryActions?.invoke(this) + IvyButton( + size = ButtonSize.Small, + visibility = Visibility.Focused, + feeling = Feeling.Positive, + text = ctaText, + icon = ctaIcon, + onClick = onCtaClick + ) + } +} + + +@Preview +@Composable +private fun Preview() { + IvyPreview { + BaseBottomSheet( + ctaText = "CTA", + ctaIcon = R.drawable.ic_round_add_24, + secondaryActions = { + DeleteButton { + + } + SpacerHor(width = 12.dp) + }, + onCtaClick = {}, + ) { + H1(text = "Content") + } + } +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/component/CategoryComponent.kt b/transaction/src/main/java/com/ivy/transaction/component/CategoryComponent.kt new file mode 100644 index 0000000..5ebf36a --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/component/CategoryComponent.kt @@ -0,0 +1,114 @@ +package com.ivy.transaction.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.dummyCategoryUi +import com.ivy.core.ui.data.icon.IconSize +import com.ivy.core.ui.icon.ItemIcon +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.rememberContrast +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview +import com.ivy.transaction.R + +@Composable +internal fun CategoryComponent( + category: CategoryUi?, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + if (category != null) { + CategoryButton( + modifier = modifier, + category = category, + onClick = onClick, + ) + } else { + AddCategoryButton( + modifier = modifier, + onClick = onClick + ) + } +} + +@Composable +private fun CategoryButton( + category: CategoryUi, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Row( + modifier = modifier + .clip(UI.shapes.rounded) + .background(category.color, UI.shapes.rounded) + .clickable(onClick = onClick) + .padding(start = 16.dp, end = 24.dp) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val contrast = rememberContrast(category.color) + ItemIcon( + itemIcon = category.icon, + size = IconSize.S, + tint = contrast + ) + SpacerHor(width = 12.dp) + B2(text = category.name, color = contrast) + } +} + +@Composable +private fun AddCategoryButton( + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + IvyButton( + modifier = modifier, + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = stringResource(R.string.add_category), + icon = R.drawable.ic_round_add_24, + onClick = onClick + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview_NoCategory() { + ComponentPreview { + CategoryComponent( + category = null, + onClick = {} + ) + } +} + +@Preview +@Composable +private fun Preview_WithCategory() { + ComponentPreview { + CategoryComponent( + category = dummyCategoryUi(), + onClick = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/component/DescriptionComponent.kt b/transaction/src/main/java/com/ivy/transaction/component/DescriptionComponent.kt new file mode 100644 index 0000000..ce4ea60 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/component/DescriptionComponent.kt @@ -0,0 +1,100 @@ +package com.ivy.transaction.component + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview +import com.ivy.transaction.R + +@Composable +internal fun DescriptionComponent( + description: String?, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + if (description != null) { + Description( + modifier = modifier, + description = description, + onClick = onClick, + ) + } else { + AddDescriptionButton( + modifier = modifier, + onClick = onClick + ) + } +} + +@Composable +private fun Description( + description: String, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + B2( + modifier = modifier + .fillMaxWidth() + .clip(UI.shapes.rounded) + .border(1.dp, UI.colors.neutral, UI.shapes.rounded) + .clickable(onClick = onClick) + .padding(all = 16.dp), + text = description, + fontWeight = FontWeight.Normal + ) +} + +@Composable +private fun AddDescriptionButton( + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + IvyButton( + modifier = modifier, + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = stringResource(R.string.add_description), + icon = R.drawable.ic_round_add_24, + onClick = onClick + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview_NoDescription() { + ComponentPreview { + DescriptionComponent( + description = null, + onClick = {} + ) + } +} + +@Preview +@Composable +private fun Preview_Description() { + ComponentPreview { + DescriptionComponent( + description = "Description\nand more\nand more\ntesting.", + onClick = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/component/FeeComponent.kt b/transaction/src/main/java/com/ivy/transaction/component/FeeComponent.kt new file mode 100644 index 0000000..f68d155 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/component/FeeComponent.kt @@ -0,0 +1,72 @@ +package com.ivy.transaction.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.dummyValueUi +import com.ivy.design.l0_system.UI +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview +import com.ivy.resources.R + +@Composable +internal fun FeeComponent( + fee: ValueUi, + validFee: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + if (!validFee) { + IvyButton( + modifier = modifier, + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = Feeling.Negative, + text = "Add fee", + icon = R.drawable.ic_custom_bills_s, + onClick = onClick + ) + } else { + IvyButton( + modifier = modifier, + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = Feeling.Negative, + text = "Fee ${fee.amount} ${fee.currency}", + typo = UI.typoSecond.b2, + icon = R.drawable.ic_custom_bills_s, + onClick = onClick + ) + } +} + + +// region Preview +@Preview +@Composable +private fun Preview_NoFee() { + ComponentPreview { + FeeComponent( + fee = dummyValueUi(), + validFee = false, + onClick = {} + ) + } +} + +@Preview +@Composable +private fun Preview_Fee() { + ComponentPreview { + FeeComponent( + fee = dummyValueUi(amount = "2"), + validFee = true, + onClick = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/component/TitleInput.kt b/transaction/src/main/java/com/ivy/transaction/component/TitleInput.kt new file mode 100644 index 0000000..e2a220b --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/component/TitleInput.kt @@ -0,0 +1,50 @@ +package com.ivy.transaction.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.l2_components.input.InputFieldType +import com.ivy.design.l2_components.input.IvyInputField +import com.ivy.design.util.ComponentPreview + +@Composable +internal fun TitleInput( + title: String?, + focus: FocusRequester, + modifier: Modifier = Modifier, + onTitleChange: (String) -> Unit, + onCta: () -> Unit, +) { + IvyInputField( + modifier = modifier + .focusRequester(focus) + .fillMaxWidth() + .padding(horizontal = 16.dp), + type = InputFieldType.SingleLine, + initialValue = title ?: "", + placeholder = "Title", + imeAction = ImeAction.Done, + onImeAction = { onCta() }, + onValueChange = onTitleChange, + ) +} + + +@Preview +@Composable +private fun Preview() { + ComponentPreview { + TitleInput( + title = "Title", + focus = FocusRequester(), + onTitleChange = {}, + onCta = {}, + ) + } +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/component/TitleSuggestions.kt b/transaction/src/main/java/com/ivy/transaction/component/TitleSuggestions.kt new file mode 100644 index 0000000..5f82b3c --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/component/TitleSuggestions.kt @@ -0,0 +1,83 @@ +package com.ivy.transaction.component + +import androidx.compose.animation.* +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.B2Second +import com.ivy.design.util.ComponentPreview + +@Composable +internal fun TitleSuggestions( + focused: Boolean, + suggestions: List, + modifier: Modifier = Modifier, + onSuggestionClick: (String) -> Unit, +) { + AnimatedVisibility( + modifier = modifier + .padding(horizontal = 16.dp), + visible = focused && suggestions.isNotEmpty(), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .border(1.dp, UI.colors.primary, UI.shapes.rounded) + .padding(vertical = 4.dp), + ) { + suggestions.forEachIndexed { index, suggestion -> + key(index.toString() + suggestion) { + Suggestion(suggestion = suggestion) { + onSuggestionClick(suggestion) + } + } + } + } + } +} + +@Composable +private fun Suggestion( + suggestion: String, + onClick: () -> Unit, +) { + B2Second( + modifier = Modifier + .fillMaxWidth() + .clip(UI.shapes.rounded) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 12.dp), + text = suggestion, + fontWeight = FontWeight.Normal, + ) +} + + +@Preview +@Composable +private fun TitleSuggestionsPreview() { + ComponentPreview { + TitleSuggestions( + focused = true, + suggestions = listOf( + "Suggestion 1", + "Suggestion 2", + "Suggestion 3", + ), + onSuggestionClick = {}, + ) + } +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/component/TransactionBottomSheet.kt b/transaction/src/main/java/com/ivy/transaction/component/TransactionBottomSheet.kt new file mode 100644 index 0000000..6260037 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/component/TransactionBottomSheet.kt @@ -0,0 +1,217 @@ +package com.ivy.transaction.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.domain.pure.dummy.dummyValue +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.dummyValueUi +import com.ivy.core.ui.account.AccountButton +import com.ivy.core.ui.account.create.CreateAccountModal +import com.ivy.core.ui.account.pick.SingleAccountPickerModal +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.core.ui.value.AmountCurrencyBig +import com.ivy.core.ui.value.AmountCurrencySmall +import com.ivy.data.Value +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.util.IvyPreview +import com.ivy.resources.R +import com.ivy.transaction.modal.AmountModalWithAccounts + +@Composable +internal fun BoxScope.AmountAccountSheet( + amountUi: ValueUi, + amount: Value, + amountBaseCurrency: ValueUi?, + account: AccountUi, + ctaText: String, + @DrawableRes + ctaIcon: Int, + accountPickerModal: IvyModal, + amountModal: IvyModal, + modifier: Modifier = Modifier, + secondaryActions: (@Composable RowScope.() -> Unit)? = null, + onAccountChange: (AccountUi) -> Unit, + onAmountEnter: (Value) -> Unit, + onCtaClick: () -> Unit, +) { + BaseBottomSheet( + modifier = modifier, + ctaText = ctaText, + ctaIcon = ctaIcon, + onCtaClick = onCtaClick, + secondaryActions = secondaryActions, + ) { + AmountAccountRow( + amount = amountUi, + amountBaseCurrency = amountBaseCurrency, + account = account, + onAmountClick = { + amountModal.show() + }, + onAccountClick = { + accountPickerModal.show() + } + ) + SpacerVer(height = 16.dp) + } + + Modals( + account = account, + accountPickerModal = accountPickerModal, + amount = amount, + amountModal = amountModal, + onAccountChange = onAccountChange, + onAmountEnter = onAmountEnter, + ) +} + +@Composable +private fun AmountAccountRow( + amount: ValueUi, + amountBaseCurrency: ValueUi?, + account: AccountUi, + modifier: Modifier = Modifier, + onAmountClick: () -> Unit, + onAccountClick: () -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + .clickable(onClick = onAmountClick) + .padding(start = 8.dp), + ) { + AmountCurrencyBig(value = amount) + if (amountBaseCurrency != null) { + SpacerVer(height = 4.dp) + Row { + AmountCurrencySmall( + value = amountBaseCurrency, + color = UI.colors.primary, + ) + } + } + } + AccountButton( + account = account, + onClick = onAccountClick + ) + } +} + +@Composable +private fun BoxScope.Modals( + account: AccountUi, + accountPickerModal: IvyModal, + amount: Value, + amountModal: IvyModal, + onAccountChange: (AccountUi) -> Unit, + onAmountEnter: (Value) -> Unit, +) { + SingleAccountPickerModal( + modal = accountPickerModal, + selected = account, + onSelectAccount = onAccountChange + ) + + val createAccountModal = rememberIvyModal() + AmountModalWithAccounts( + modal = amountModal, + amount = amount, + account = account, + onAddAccount = { + createAccountModal.show() + }, + onAmountEnter = onAmountEnter, + onAccountChange = onAccountChange + ) + + CreateAccountModal( + modal = createAccountModal, + level = 2, + ) +} + +// region Preview +@Preview +@Composable +private fun Preview() { + IvyPreview { + AmountAccountSheet( + amountUi = dummyValueUi(), + amount = dummyValue(), + amountBaseCurrency = null, + account = dummyAccountUi(), + ctaText = "Add", + ctaIcon = R.drawable.ic_round_add_24, + accountPickerModal = rememberIvyModal(), + amountModal = rememberIvyModal(), + onAccountChange = {}, + onAmountEnter = {}, + onCtaClick = {}, + ) + } +} + +@Preview +@Composable +private fun Preview_LongAmount() { + IvyPreview { + AmountAccountSheet( + amountUi = dummyValueUi(amount = "12345678901234567890.33"), + amount = dummyValue(), + amountBaseCurrency = dummyValueUi( + amount = "12345678901234567890.33", + currency = "BGN", + ), + account = dummyAccountUi(), + ctaText = "Add", + ctaIcon = R.drawable.ic_round_add_24, + accountPickerModal = rememberIvyModal(), + amountModal = rememberIvyModal(), + onAccountChange = {}, + onAmountEnter = {}, + onCtaClick = {}, + ) + } +} + +@Preview +@Composable +private fun Preview_LongAmount_LongAccount() { + IvyPreview { + AmountAccountSheet( + amountUi = dummyValueUi(amount = "12345678901234567890.33"), + amount = dummyValue(), + amountBaseCurrency = dummyValueUi(), + account = dummyAccountUi( + name = "Revolut Business Company 2 Account" + ), + ctaText = "Add", + ctaIcon = R.drawable.ic_round_add_24, + accountPickerModal = rememberIvyModal(), + amountModal = rememberIvyModal(), + onAccountChange = {}, + onAmountEnter = {}, + onCtaClick = {}, + ) + } +} + +// endregion \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/component/TransferBottomSheet.kt b/transaction/src/main/java/com/ivy/transaction/component/TransferBottomSheet.kt new file mode 100644 index 0000000..0a5dc0f --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/component/TransferBottomSheet.kt @@ -0,0 +1,322 @@ +package com.ivy.transaction.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.domain.pure.dummy.dummyValue +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.domain.pure.format.dummyValueUi +import com.ivy.core.ui.account.AccountButton +import com.ivy.core.ui.account.create.CreateAccountModal +import com.ivy.core.ui.account.pick.SingleAccountPickerModal +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.core.ui.value.AmountCurrencyBig +import com.ivy.data.Value +import com.ivy.design.l0_system.color.Blue2Dark +import com.ivy.design.l1_buildingBlocks.B1 +import com.ivy.design.l1_buildingBlocks.IconRes +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.util.IvyPreview +import com.ivy.transaction.R +import com.ivy.transaction.modal.AmountModalWithAccounts + +@Composable +fun BoxScope.TransferBottomSheet( + accountFrom: AccountUi, + amountFromUi: ValueUi, + amountFrom: Value, + accountTo: AccountUi, + amountToUi: ValueUi, + amountTo: Value, + + modifier: Modifier = Modifier, + secondaryActions: (@Composable RowScope.() -> Unit)? = null, + ctaText: String, + @DrawableRes + ctaIcon: Int, + onCtaClick: () -> Unit, + onFromAccountChange: (AccountUi) -> Unit, + onToAccountChange: (AccountUi) -> Unit, + onFromAmountChange: (Value) -> Unit, + onToAmountChange: (Value) -> Unit, +) { + val fromAccountPickerModal = rememberIvyModal() + val toAccountPickerModal = rememberIvyModal() + val fromAmountModal = rememberIvyModal() + val toAmountModal = rememberIvyModal() + + BaseBottomSheet( + modifier = modifier, + ctaText = ctaText, + ctaIcon = ctaIcon, + secondaryActions = secondaryActions, + onCtaClick = onCtaClick + ) { + TransferContent( + accountFrom = accountFrom, + amountFrom = amountFromUi, + accountTo = accountTo, + amountTo = amountToUi, + + onFromAccountClick = { + fromAccountPickerModal.show() + }, + onToAccountClick = { + toAccountPickerModal.show() + }, + onFromAmountClick = { + fromAmountModal.show() + }, + onToAmountClick = { + toAmountModal.show() + }, + ) + SpacerVer(height = 16.dp) + } + + Modals( + accountFrom = accountFrom, + amountFrom = amountFrom, + accountTo = accountTo, + amountTo = amountTo, + fromAccountPickerModal = fromAccountPickerModal, + toAccountPickerModal = toAccountPickerModal, + fromAmountModal = fromAmountModal, + toAmountModal = toAmountModal, + onFromAccountChange = onFromAccountChange, + onToAccountChange = onToAccountChange, + onFromAmountChange = onFromAmountChange, + onToAmountChange = onToAmountChange, + ) +} + +@Composable +private fun TransferContent( + accountFrom: AccountUi, + amountFrom: ValueUi, + accountTo: AccountUi, + amountTo: ValueUi, + + onFromAccountClick: () -> Unit, + onFromAmountClick: () -> Unit, + onToAccountClick: () -> Unit, + onToAmountClick: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + AccountAmount( + modifier = Modifier.weight(1f), + label = "From", + alignment = Alignment.Start, + account = accountFrom, + amount = amountFrom, + onAccountClick = onFromAccountClick, + onAmountClick = onFromAmountClick, + ) + IconRes(icon = R.drawable.ic_arrow_right) + AccountAmount( + modifier = Modifier.weight(1f), + label = "To", + alignment = Alignment.End, + account = accountTo, + amount = amountTo, + onAccountClick = onToAccountClick, + onAmountClick = onToAmountClick, + ) + } +} + +@Composable +private fun BoxScope.Modals( + accountFrom: AccountUi, + amountFrom: Value, + accountTo: AccountUi, + amountTo: Value, + + fromAccountPickerModal: IvyModal, + toAccountPickerModal: IvyModal, + fromAmountModal: IvyModal, + toAmountModal: IvyModal, + + onFromAccountChange: (AccountUi) -> Unit, + onToAccountChange: (AccountUi) -> Unit, + onFromAmountChange: (Value) -> Unit, + onToAmountChange: (Value) -> Unit, +) { + // From + SingleAccountPickerModal( + modal = fromAccountPickerModal, + selected = accountFrom, + onSelectAccount = onFromAccountChange + ) + // To + SingleAccountPickerModal( + modal = toAccountPickerModal, + selected = accountTo, + onSelectAccount = onToAccountChange + ) + + val createAccountModal = rememberIvyModal() + // From + AmountModalWithAccounts( + modal = fromAmountModal, + key = "from", + amount = amountFrom, + account = accountFrom, + onAddAccount = { + createAccountModal.show() + }, + onAmountEnter = onFromAmountChange, + onAccountChange = onFromAccountChange + ) + // To + AmountModalWithAccounts( + modal = toAmountModal, + key = "to", + amount = amountTo, + account = accountTo, + onAddAccount = { + createAccountModal.show() + }, + onAmountEnter = onToAmountChange, + onAccountChange = onToAccountChange + ) + + CreateAccountModal( + modal = createAccountModal, + level = 2, + ) +} + +@Composable +private fun AccountAmount( + account: AccountUi, + amount: ValueUi, + label: String, + alignment: Alignment.Horizontal, + modifier: Modifier = Modifier, + onAccountClick: () -> Unit, + onAmountClick: () -> Unit, +) { + Column( + modifier = modifier.padding(horizontal = 12.dp), + horizontalAlignment = alignment + ) { + B1( + modifier = Modifier.padding(horizontal = 16.dp), + text = label, + fontWeight = FontWeight.SemiBold, + ) + SpacerVer(height = 8.dp) + AccountButton(account = account, onClick = onAccountClick) + Column( + modifier = Modifier + .clickable(onClick = onAmountClick) + .padding(horizontal = 12.dp) + .padding(top = 8.dp), + horizontalAlignment = alignment, + ) { + AmountCurrencyBig(value = amount) + } + } +} + + +// region Previews +@Preview +@Composable +private fun Preview() { + IvyPreview { + TransferBottomSheet( + accountFrom = dummyAccountUi(), + amountFromUi = dummyValueUi(), + accountTo = dummyAccountUi(), + amountToUi = dummyValueUi(), + amountTo = dummyValue(), // used only for modals + amountFrom = dummyValue(), // used only for modals + ctaText = "Add", + ctaIcon = R.drawable.ic_round_add_24, + onCtaClick = {}, + onFromAccountChange = {}, + onToAccountChange = {}, + onFromAmountChange = {}, + onToAmountChange = {}, + ) + } +} + +@Preview +@Composable +private fun Preview_Normal() { + IvyPreview { + TransferBottomSheet( + accountFrom = dummyAccountUi( + name = "DSK Bank", + color = Blue2Dark, + ), + amountFromUi = dummyValueUi( + amount = "400" + ), + accountTo = dummyAccountUi( + name = "Cash", + ), + amountToUi = dummyValueUi( + amount = "400" + ), + amountTo = dummyValue(), // used only for modals + amountFrom = dummyValue(), // used only for modals + ctaText = "Add", + ctaIcon = R.drawable.ic_round_add_24, + onCtaClick = {}, + onFromAccountChange = {}, + onToAccountChange = {}, + onFromAmountChange = {}, + onToAmountChange = {}, + ) + } +} + +@Preview +@Composable +private fun Preview_LongMultiCurrency() { + IvyPreview { + TransferBottomSheet( + accountFrom = dummyAccountUi( + name = "Revolut Business EUR", + color = Blue2Dark, + ), + amountFromUi = dummyValueUi( + amount = "160,235.30", + currency = "EUR" + ), + accountTo = dummyAccountUi( + name = "Bank Company Account BGN", + ), + amountToUi = dummyValueUi( + amount = "310,818.94", + currency = "BGN", + ), + amountTo = dummyValue(), // used only for modals + amountFrom = dummyValue(), // used only for modals + ctaText = "Add", + ctaIcon = R.drawable.ic_round_add_24, + onCtaClick = {}, + onFromAccountChange = {}, + onToAccountChange = {}, + onFromAmountChange = {}, + onToAmountChange = {}, + ) + } +} +// endregion \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/component/TransferRateComponent.kt b/transaction/src/main/java/com/ivy/transaction/component/TransferRateComponent.kt new file mode 100644 index 0000000..f456c40 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/component/TransferRateComponent.kt @@ -0,0 +1,48 @@ +package com.ivy.transaction.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.ivy.design.l0_system.UI +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview +import com.ivy.resources.R +import com.ivy.transaction.data.TransferRateUi + +@Composable +fun TransferRateComponent( + rate: TransferRateUi, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + IvyButton( + modifier = modifier, + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = Feeling.Custom(UI.colors.redP1), + text = "${rate.fromCurrency}-${rate.toCurrency}: ${rate.rateValueFormatted}", + icon = R.drawable.round_currency_exchange_24, + typo = UI.typoSecond.b2, + onClick = onClick, + ) +} + + +@Preview +@Composable +private fun Preview() { + ComponentPreview { + TransferRateComponent( + rate = TransferRateUi( + rateValueFormatted = "1.2", + rateValue = 1.2, + fromCurrency = "EUR", + toCurrency = "USD", + ), + onClick = {} + ) + } +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/component/TrnScreenToolbar.kt b/transaction/src/main/java/com/ivy/transaction/component/TrnScreenToolbar.kt new file mode 100644 index 0000000..d1f97b2 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/component/TrnScreenToolbar.kt @@ -0,0 +1,56 @@ +package com.ivy.transaction.component + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.l1_buildingBlocks.SpacerWeight +import com.ivy.design.l2_components.modal.CloseButton +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview + +@Composable +internal fun TrnScreenToolbar( + onClose: () -> Unit, + actions: @Composable RowScope.() -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CloseButton(onClick = onClose) + SpacerWeight(weight = 1f) + actions() + } +} + + +@Preview +@Composable +private fun Preview() { + ComponentPreview { + TrnScreenToolbar( + onClose = {}, + actions = { + IvyButton( + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + text = "Test", + icon = null, + onClick = {} + ) + } + ) + } +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/component/TrnTimeComponent.kt b/transaction/src/main/java/com/ivy/transaction/component/TrnTimeComponent.kt new file mode 100644 index 0000000..77e27f4 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/component/TrnTimeComponent.kt @@ -0,0 +1,97 @@ +package com.ivy.transaction.component + +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.data.transaction.TrnTimeUi +import com.ivy.design.l0_system.UI +import com.ivy.design.l0_system.color.Orange +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.ComponentPreview +import com.ivy.transaction.R + +@Composable +internal fun TrnTimeComponent( + extendedTrnTime: TrnTimeUi, + modifier: Modifier = Modifier, + onDateClick: () -> Unit, + onTimeClick: () -> Unit, +) { + val feeling = when (extendedTrnTime) { + is TrnTimeUi.Actual -> Feeling.Positive + is TrnTimeUi.Due -> Feeling.Custom(Orange) + } + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + IvyButton( + modifier = Modifier.weight(1.5f), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = feeling, + text = when (extendedTrnTime) { + is TrnTimeUi.Actual -> extendedTrnTime.actualDate + is TrnTimeUi.Due -> extendedTrnTime.dueOnDate + }, + icon = R.drawable.ic_round_calendar_month_24, + typo = UI.typoSecond.b2, + onClick = onDateClick + ) + SpacerHor(width = 8.dp) + IvyButton( + modifier = Modifier.weight(1f), + size = ButtonSize.Big, + visibility = Visibility.Medium, + feeling = feeling, + text = when (extendedTrnTime) { + is TrnTimeUi.Actual -> extendedTrnTime.actualTime + is TrnTimeUi.Due -> extendedTrnTime.dueOnTime + }, + icon = R.drawable.round_time_24, + typo = UI.typoSecond.b2, + onClick = onTimeClick + ) + } +} + + +// region Preview +@Preview +@Composable +private fun Preview_Actual() { + ComponentPreview { + TrnTimeComponent( + extendedTrnTime = TrnTimeUi.Actual( + actualDate = "Dec 21, 2021", + actualTime = "21:46", + ), + onDateClick = {}, + onTimeClick = {}, + ) + } +} + +@Preview +@Composable +private fun Preview_Due() { + ComponentPreview { + TrnTimeComponent( + extendedTrnTime = TrnTimeUi.Due( + dueOnDate = "Dec 21, 2021", + dueOnTime = "01:46 pm", + upcoming = true + ), + onDateClick = {}, + onTimeClick = {}, + ) + } +} +// endregion \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/create/CreateTrnController.kt b/transaction/src/main/java/com/ivy/transaction/create/CreateTrnController.kt new file mode 100644 index 0000000..de59171 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/create/CreateTrnController.kt @@ -0,0 +1,97 @@ +package com.ivy.transaction.create + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.focus.FocusRequester +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.util.KeyboardController +import com.ivy.transaction.create.action.CreateTrnStepsAct +import com.ivy.transaction.create.data.CreateTrnFlow +import com.ivy.transaction.create.data.CreateTrnStep +import javax.inject.Inject + +class CreateTrnController @Inject constructor( + private val createTrnStepsAct: CreateTrnStepsAct, +) { + val uiFlow: CreateTrnFlowUiState = CreateTrnFlowUiState.default() + private var createTrnFlow: CreateTrnFlow? = null + + private val titleStep = object : FlowStep { + override fun execute() { + uiFlow.titleFocus.requestFocus() + uiFlow.keyboardController.show() + } + } + private val amountStep = ModalStep(uiFlow.amountModal) + private val categoryStep = ModalStep(uiFlow.categoryPickerModal) + private val accountStep = ModalStep(uiFlow.accountPickerModal) + private val descriptionStep = ModalStep(uiFlow.descriptionModal) + private val dateStep = ModalStep(uiFlow.dateModal) + private val timeStep = ModalStep(uiFlow.timeModal) + + private fun flowStep(step: CreateTrnStep): FlowStep = when (step) { + CreateTrnStep.Title -> titleStep + CreateTrnStep.Amount -> amountStep + CreateTrnStep.Category -> categoryStep + CreateTrnStep.Account -> accountStep + CreateTrnStep.Description -> descriptionStep + CreateTrnStep.Date -> dateStep + CreateTrnStep.Time -> timeStep + } + + // region Public + suspend fun startFlow() { + val createTrnFlow = createTrnStepsAct(Unit).also { + this.createTrnFlow = it + } + flowStep(createTrnFlow.first).execute() + } + + fun nextStep(after: CreateTrnStep) { + createTrnFlow?.steps?.get(after)?.let(::flowStep)?.execute() + } + + fun hideKeyboard() { + uiFlow.keyboardController.hide() + } + // endregion + + + // region Helper classes + private interface FlowStep { + fun execute() + } + + class ModalStep(private val modal: IvyModal) : FlowStep { + override fun execute() { + modal.show() + } + } + // endregion +} + +// It would be better to be nested class +// But I'm not sure if it's Compose optimized that way +@Immutable +data class CreateTrnFlowUiState( + val keyboardController: KeyboardController, + val titleFocus: FocusRequester, + val amountModal: IvyModal, + val categoryPickerModal: IvyModal, + val accountPickerModal: IvyModal, + val descriptionModal: IvyModal, + val dateModal: IvyModal, + val timeModal: IvyModal, +) { + companion object { + fun default() = CreateTrnFlowUiState( + keyboardController = KeyboardController(), + titleFocus = FocusRequester(), + amountModal = IvyModal(), + categoryPickerModal = IvyModal(), + accountPickerModal = IvyModal(), + descriptionModal = IvyModal(), + dateModal = IvyModal(), + timeModal = IvyModal(), + ) + } +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/create/action/CreateTrnStepsAct.kt b/transaction/src/main/java/com/ivy/transaction/create/action/CreateTrnStepsAct.kt new file mode 100644 index 0000000..0c09a1a --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/create/action/CreateTrnStepsAct.kt @@ -0,0 +1,19 @@ +package com.ivy.transaction.create.action + +import com.ivy.core.domain.action.Action +import com.ivy.transaction.create.data.CreateTrnFlow +import com.ivy.transaction.create.data.CreateTrnStep +import javax.inject.Inject + +class CreateTrnStepsAct @Inject constructor( +) : Action() { + + override suspend fun action(input: Unit): CreateTrnFlow = CreateTrnFlow( + first = CreateTrnStep.Amount, + steps = mapOf( + CreateTrnStep.Amount to CreateTrnStep.Category, + CreateTrnStep.Category to CreateTrnStep.Title, + ) + ) + +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/create/action/PreselectedAccountAct.kt b/transaction/src/main/java/com/ivy/transaction/create/action/PreselectedAccountAct.kt new file mode 100644 index 0000000..bb9c290 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/create/action/PreselectedAccountAct.kt @@ -0,0 +1,41 @@ +package com.ivy.transaction.create.action + +import com.ivy.core.domain.action.Action +import com.ivy.core.domain.action.account.AccountByIdAct +import com.ivy.core.domain.action.account.AccountsAct +import com.ivy.core.persistence.datastore.IvyDataStore +import com.ivy.core.ui.action.mapping.account.MapAccountUiAct +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.transaction.create.action.PreselectedAccountAct.Input +import com.ivy.transaction.create.persistence.LastUsedAccountIdKey +import kotlinx.coroutines.flow.firstOrNull +import javax.inject.Inject + +class PreselectedAccountAct @Inject constructor( + private val dataStore: IvyDataStore, + private val accountByIdAct: AccountByIdAct, + private val mapAccountUiAct: MapAccountUiAct, + private val accountsAct: AccountsAct, + private val lastUsedAccountId: LastUsedAccountIdKey, +) : Action() { + + data class Input( + val preselectedAccountId: String?, + ) + + override suspend fun action(input: Input): AccountUi? = + input.preselectedAccount() ?: lastUsedAccount() ?: firstAccount() + + private suspend fun Input.preselectedAccount(): AccountUi? = + preselectedAccountId?.let { accountByIdAct(it) } + ?.let { mapAccountUiAct(it) } + + private suspend fun lastUsedAccount(): AccountUi? = + dataStore.get(lastUsedAccountId.key).firstOrNull() + ?.let { accountByIdAct(it) } + ?.let { mapAccountUiAct(it) } + + private suspend fun firstAccount(): AccountUi? = + accountsAct(Unit).firstOrNull() + ?.let { mapAccountUiAct(it) } +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/create/action/WriteLastUsedAccount.kt b/transaction/src/main/java/com/ivy/transaction/create/action/WriteLastUsedAccount.kt new file mode 100644 index 0000000..20583b2 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/create/action/WriteLastUsedAccount.kt @@ -0,0 +1,19 @@ +package com.ivy.transaction.create.action + +import com.ivy.core.domain.action.Action +import com.ivy.core.persistence.datastore.IvyDataStore +import com.ivy.transaction.create.persistence.LastUsedAccountIdKey +import javax.inject.Inject + +class WriteLastUsedAccount @Inject constructor( + private val dataStore: IvyDataStore, + private val lastUsedAccountId: LastUsedAccountIdKey, +) : Action() { + data class Input( + val accountId: String, + ) + + override suspend fun action(input: Input) { + dataStore.put(lastUsedAccountId.key, input.accountId) + } +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/create/data/CreateTrnFlow.kt b/transaction/src/main/java/com/ivy/transaction/create/data/CreateTrnFlow.kt new file mode 100644 index 0000000..6d30913 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/create/data/CreateTrnFlow.kt @@ -0,0 +1,6 @@ +package com.ivy.transaction.create.data + +data class CreateTrnFlow( + val first: CreateTrnStep, + val steps: Map +) \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/create/data/CreateTrnStep.kt b/transaction/src/main/java/com/ivy/transaction/create/data/CreateTrnStep.kt new file mode 100644 index 0000000..091b7e0 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/create/data/CreateTrnStep.kt @@ -0,0 +1,11 @@ +package com.ivy.transaction.create.data + +enum class CreateTrnStep(val key: String) { + Title("title"), + Description("description"), + Amount("amount"), + Account("account"), + Category("category"), + Date("date"), + Time("time"), +} diff --git a/transaction/src/main/java/com/ivy/transaction/create/persistence/LastUsedAccountIdKey.kt b/transaction/src/main/java/com/ivy/transaction/create/persistence/LastUsedAccountIdKey.kt new file mode 100644 index 0000000..97de5e4 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/create/persistence/LastUsedAccountIdKey.kt @@ -0,0 +1,8 @@ +package com.ivy.transaction.create.persistence + +import androidx.datastore.preferences.core.stringPreferencesKey +import javax.inject.Inject + +class LastUsedAccountIdKey @Inject constructor() { + val key by lazy { stringPreferencesKey("last_used_account_id") } +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/create/transfer/NewTransferEvent.kt b/transaction/src/main/java/com/ivy/transaction/create/transfer/NewTransferEvent.kt new file mode 100644 index 0000000..a55b388 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/create/transfer/NewTransferEvent.kt @@ -0,0 +1,25 @@ +package com.ivy.transaction.create.transfer + +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.data.Value +import com.ivy.data.transaction.TrnTime + +sealed interface NewTransferEvent { + object Initial : NewTransferEvent + object Add : NewTransferEvent + object Close : NewTransferEvent + + data class TransferAmountChange(val amount: Value) : NewTransferEvent + data class FromAmountChange(val amount: Value) : NewTransferEvent + data class ToAmountChange(val amount: Value) : NewTransferEvent + data class TitleChange(val title: String) : NewTransferEvent + data class DescriptionChange(val description: String?) : NewTransferEvent + data class FromAccountChange(val account: AccountUi) : NewTransferEvent + data class ToAccountChange(val account: AccountUi) : NewTransferEvent + data class CategoryChange(val category: CategoryUi?) : NewTransferEvent + data class TrnTimeChange(val time: TrnTime) : NewTransferEvent + data class FeePercent(val percent: Double) : NewTransferEvent + data class FeeChange(val value: Value?) : NewTransferEvent + data class RateChange(val newRate: Double) : NewTransferEvent +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/create/transfer/NewTransferScreen.kt b/transaction/src/main/java/com/ivy/transaction/create/transfer/NewTransferScreen.kt new file mode 100644 index 0000000..a0ccb8a --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/create/transfer/NewTransferScreen.kt @@ -0,0 +1,337 @@ +package com.ivy.transaction.create.transfer + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ivy.core.domain.pure.dummy.dummyActual +import com.ivy.core.domain.pure.format.dummyCombinedValueUi +import com.ivy.core.ui.account.create.CreateAccountModal +import com.ivy.core.ui.category.pick.CategoryPickerModal +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.core.ui.data.dummyCategoryUi +import com.ivy.core.ui.data.transaction.dummyTrnTimeActualUi +import com.ivy.core.ui.modal.RateModal +import com.ivy.design.l0_system.color.Blue2Dark +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.keyboardPadding +import com.ivy.design.util.keyboardShownState +import com.ivy.resources.R +import com.ivy.transaction.component.* +import com.ivy.transaction.create.CreateTrnFlowUiState +import com.ivy.transaction.data.TransferRateUi +import com.ivy.transaction.modal.* + +@Composable +fun BoxScope.NewTransferScreen() { + val viewModel: NewTransferViewModel = hiltViewModel() + val state by viewModel.uiState.collectAsState() + + state.createFlow.keyboardController.wire() + + LaunchedEffect(Unit) { + viewModel.onEvent(NewTransferEvent.Initial) + } + + UI(state = state, onEvent = viewModel::onEvent) +} + +@Composable +private fun BoxScope.UI( + state: NewTransferState, + onEvent: (NewTransferEvent) -> Unit, +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + ) { + item(key = "toolbar") { + SpacerVer(height = 24.dp) + TrnScreenToolbar( + onClose = { + onEvent(NewTransferEvent.Close) + }, + actions = {}, + ) + } + item(key = "title") { + SpacerVer(height = 24.dp) + var titleFocused by remember { mutableStateOf(false) } + val keyboardShown by keyboardShownState() + TitleInput( + modifier = Modifier.onFocusChanged { + titleFocused = it.isFocused || it.hasFocus + }, + title = state.title, + focus = state.createFlow.titleFocus, + onTitleChange = { onEvent(NewTransferEvent.TitleChange(it)) }, + onCta = { onEvent(NewTransferEvent.Add) } + ) + TitleSuggestions( + focused = titleFocused && keyboardShown, + suggestions = state.titleSuggestions, + onSuggestionClick = { onEvent(NewTransferEvent.TitleChange(it)) } + ) + } + item(key = "category") { + SpacerVer(height = 12.dp) + CategoryComponent( + modifier = Modifier.padding(horizontal = 16.dp), + category = state.category + ) { + state.createFlow.categoryPickerModal.show() + } + } + item(key = "description") { + SpacerVer(height = 24.dp) + DescriptionComponent( + modifier = Modifier.padding(horizontal = 16.dp), + description = state.description + ) { + state.createFlow.descriptionModal.show() + } + } + item(key = "trn_time") { + SpacerVer(height = 12.dp) + TrnTimeComponent( + modifier = Modifier.padding(horizontal = 16.dp), + extendedTrnTime = state.timeUi, + onDateClick = { + state.createFlow.dateModal.show() + }, + onTimeClick = { + state.createFlow.timeModal.show() + } + ) + } + item(key = "fee_rate") { + SpacerVer(height = 12.dp) + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + FeeComponent( + fee = state.fee.valueUi, + validFee = state.fee.value.amount > 0 + ) { + state.feeModal.show() + } + if (state.rate != null) { + SpacerHor(width = 12.dp) + TransferRateComponent( + modifier = Modifier.weight(1f), + rate = state.rate, + ) { + state.rateModal.show() + } + } + } + } + item(key = "last_item_spacer") { + val keyboardShown by keyboardShownState() + if (keyboardShown) { + SpacerVer(height = keyboardPadding()) + } + // To account for bottom sheet's height + SpacerVer(height = 520.dp) + } + } + + TransferBottomSheet( + accountFrom = state.accountFrom, + amountFromUi = state.amountFrom.valueUi, + amountFrom = state.amountFrom.value, + accountTo = state.accountTo, + amountToUi = state.amountTo.valueUi, + amountTo = state.amountTo.value, + ctaText = stringResource(R.string.add), + ctaIcon = R.drawable.ic_round_add_24, + onCtaClick = { + onEvent(NewTransferEvent.Add) + }, + onFromAccountChange = { + onEvent(NewTransferEvent.FromAccountChange(it)) + }, + onToAccountChange = { + onEvent(NewTransferEvent.ToAccountChange(it)) + }, + onFromAmountChange = { + onEvent(NewTransferEvent.FromAmountChange(it)) + }, + onToAmountChange = { + onEvent(NewTransferEvent.ToAmountChange(it)) + }, + ) + + Modals(state = state, onEvent = onEvent) +} + +@Composable +private fun BoxScope.Modals( + state: NewTransferState, + onEvent: (NewTransferEvent) -> Unit +) { + CategoryPickerModal( + modal = state.createFlow.categoryPickerModal, + selected = state.category, + trnType = null, + onPick = { + onEvent(NewTransferEvent.CategoryChange(it)) + } + ) + + DescriptionModal( + modal = state.createFlow.descriptionModal, + initialDescription = state.description, + onDescriptionChange = { + onEvent(NewTransferEvent.DescriptionChange(it)) + } + ) + + TrnDateModal( + modal = state.createFlow.dateModal, + trnTime = state.time, + onTrnTimeChange = { + onEvent(NewTransferEvent.TrnTimeChange(it)) + } + ) + TrnTimeModal( + modal = state.createFlow.timeModal, + trnTime = state.time, + onTrnTimeChange = { + onEvent(NewTransferEvent.TrnTimeChange(it)) + } + ) + + // Fee modal + FeeModal( + modal = state.feeModal, + fee = state.fee.value, + onRemoveFee = { + onEvent(NewTransferEvent.FeeChange(null)) + }, + onFeePercent = { + onEvent(NewTransferEvent.FeePercent(it)) + }, + onFeeChange = { + onEvent(NewTransferEvent.FeeChange(it)) + } + ) + + if (state.rate != null) { + RateModal( + modal = state.rateModal, + key = "transfer_rate", + rate = state.rate.rateValue, + fromCurrency = state.rate.fromCurrency, + toCurrency = state.rate.toCurrency, + onRateChange = { + onEvent(NewTransferEvent.RateChange(it)) + } + ) + } + + val createAccountModal = rememberIvyModal() + TransferAmountModal( + modal = state.createFlow.amountModal, + amount = state.amountFrom.value, + fromAccount = state.accountFrom, + toAccount = state.accountTo, + onAddAccount = { + createAccountModal.show() + }, + onAmountEnter = { + onEvent(NewTransferEvent.TransferAmountChange(it)) + }, + onFromAccountChange = { + onEvent(NewTransferEvent.FromAccountChange(it)) + }, + onToAccountChange = { + onEvent(NewTransferEvent.ToAccountChange(it)) + } + ) + + CreateAccountModal( + modal = createAccountModal, + level = 2, + ) +} + +// region Previews +@Preview +@Composable +private fun Preview() { + IvyPreview { + UI( + state = NewTransferState( + accountFrom = dummyAccountUi( + name = "Personal Bank", + color = Blue2Dark, + ), + amountFrom = dummyCombinedValueUi(), + accountTo = dummyAccountUi(name = "Cash"), + amountTo = dummyCombinedValueUi(), + category = dummyCategoryUi(), + description = null, + timeUi = dummyTrnTimeActualUi(), + time = dummyActual(), + title = null, + fee = dummyCombinedValueUi(), + rate = null, + + titleSuggestions = listOf("Title 1", "Title 2"), + createFlow = CreateTrnFlowUiState.default(), + feeModal = rememberIvyModal(), + rateModal = rememberIvyModal(), + ), + onEvent = {} + ) + } +} + +@Preview +@Composable +private fun Preview_Filled() { + IvyPreview { + UI( + state = NewTransferState( + accountFrom = dummyAccountUi( + name = "Personal Bank", + color = Blue2Dark, + ), + amountFrom = dummyCombinedValueUi(amount = 400.0), + accountTo = dummyAccountUi(name = "Cash"), + amountTo = dummyCombinedValueUi(amount = 400.0), + category = dummyCategoryUi(), + description = "Need some cash", + timeUi = dummyTrnTimeActualUi(), + time = dummyActual(), + title = "ATM Withdrawal", + fee = dummyCombinedValueUi(amount = 2.0), + rate = TransferRateUi( + rateValue = 1.95, + rateValueFormatted = "1.95", + fromCurrency = "EUR", + toCurrency = "BGN", + ), + + titleSuggestions = listOf("Title 1", "Title 2"), + createFlow = CreateTrnFlowUiState.default(), + feeModal = rememberIvyModal(), + rateModal = rememberIvyModal(), + ), + onEvent = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/create/transfer/NewTransferState.kt b/transaction/src/main/java/com/ivy/transaction/create/transfer/NewTransferState.kt new file mode 100644 index 0000000..dc57a3d --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/create/transfer/NewTransferState.kt @@ -0,0 +1,32 @@ +package com.ivy.transaction.create.transfer + +import androidx.compose.runtime.Immutable +import com.ivy.core.domain.pure.format.CombinedValueUi +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.transaction.TrnTimeUi +import com.ivy.data.transaction.TrnTime +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.transaction.create.CreateTrnFlowUiState +import com.ivy.transaction.data.TransferRateUi + +@Immutable +data class NewTransferState( + val accountFrom: AccountUi, + val accountTo: AccountUi, + val amountFrom: CombinedValueUi, + val amountTo: CombinedValueUi, + + val category: CategoryUi?, + val timeUi: TrnTimeUi, + val time: TrnTime, + val title: String?, + val description: String?, + val fee: CombinedValueUi, + val rate: TransferRateUi?, + + val titleSuggestions: List, + val createFlow: CreateTrnFlowUiState, + val feeModal: IvyModal, + val rateModal: IvyModal, +) \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/create/transfer/NewTransferViewModel.kt b/transaction/src/main/java/com/ivy/transaction/create/transfer/NewTransferViewModel.kt new file mode 100644 index 0000000..7f2260c --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/create/transfer/NewTransferViewModel.kt @@ -0,0 +1,350 @@ +package com.ivy.transaction.create.transfer + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.action.account.AccountByIdAct +import com.ivy.core.domain.action.account.AccountsAct +import com.ivy.core.domain.action.category.CategoryByIdAct +import com.ivy.core.domain.action.exchange.ExchangeAct +import com.ivy.core.domain.action.transaction.transfer.ModifyTransfer +import com.ivy.core.domain.action.transaction.transfer.TransferData +import com.ivy.core.domain.action.transaction.transfer.WriteTransferAct +import com.ivy.core.domain.pure.format.CombinedValueUi +import com.ivy.core.domain.pure.util.combine +import com.ivy.core.domain.pure.util.flattenLatest +import com.ivy.core.domain.pure.util.takeIfNotBlank +import com.ivy.core.ui.action.mapping.account.MapAccountUiAct +import com.ivy.core.ui.action.mapping.trn.MapTrnTimeUiAct +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.core.ui.data.transaction.TrnTimeUi +import com.ivy.data.Sync +import com.ivy.data.SyncState +import com.ivy.data.Value +import com.ivy.data.transaction.TrnTime +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.navigation.Navigator +import com.ivy.transaction.action.TitleSuggestionsFlow +import com.ivy.transaction.create.CreateTrnController +import com.ivy.transaction.data.TransferRateUi +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import java.text.DecimalFormat +import javax.inject.Inject + +@HiltViewModel +class NewTransferViewModel @Inject constructor( + private val timeProvider: TimeProvider, + private val titleSuggestionsFlow: TitleSuggestionsFlow, + private val mapTrnTimeUiAct: MapTrnTimeUiAct, + private val navigator: Navigator, + private val accountByIdAct: AccountByIdAct, + private val categoryByIdAct: CategoryByIdAct, + private val accountsAct: AccountsAct, + private val mapAccountUiAct: MapAccountUiAct, + private val writeTransferAct: WriteTransferAct, + private val exchangeAct: ExchangeAct, + private val createTrnController: CreateTrnController, +) : SimpleFlowViewModel() { + private val feeModal = IvyModal() + private val rateModal = IvyModal() + + override val initialUi = NewTransferState( + accountFrom = dummyAccountUi(), + accountTo = dummyAccountUi(), + amountFrom = CombinedValueUi.initial(), + amountTo = CombinedValueUi.initial(), + category = null, + timeUi = TrnTimeUi.Actual("", ""), + time = TrnTime.Actual(timeProvider.timeNow()), + title = null, + description = null, + fee = CombinedValueUi.initial(), + rate = null, + + titleSuggestions = emptyList(), + createFlow = createTrnController.uiFlow, + feeModal = feeModal, + rateModal = rateModal, + ) + + // region State + private val amountFrom = MutableStateFlow(initialUi.amountFrom) + private val amountTo = MutableStateFlow(initialUi.amountTo) + private val accountFrom = MutableStateFlow(initialUi.accountFrom) + private val accountTo = MutableStateFlow(initialUi.accountTo) + private val category = MutableStateFlow(initialUi.category) + private val time = MutableStateFlow(TrnTime.Actual(timeProvider.timeNow())) + private val timeUi = MutableStateFlow(initialUi.timeUi) + private val title = MutableStateFlow(initialUi.title) + private val description = MutableStateFlow(initialUi.description) + private val fee = MutableStateFlow(initialUi.fee) + // endregion + + + override val uiFlow = combine( + amountFrom, amountTo, + accountFrom, accountTo, category, time, timeUi, + title, description, fee, + ) + { amountFrom, amountTo, + accountFrom, accountTo, category, time, timeUi, + title, description, fee -> + titleSuggestionsFlow( + TitleSuggestionsFlow.Input( + title = title, + categoryUi = category, + transfer = true, + ) + ).map { titleSuggestions -> + NewTransferState( + amountFrom = amountFrom, + amountTo = amountTo, + accountFrom = accountFrom, + accountTo = accountTo, + category = category, + time = time, + timeUi = timeUi, + title = title, + description = description, + fee = fee, + rate = if (amountFrom.value.currency != amountTo.value.currency && + amountFrom.value.amount > 0.0 && amountTo.value.amount > 0.0 + ) { + // e.g. 1 EUR to 1.96 BGN + // => EUR-BGN = 1.96 / 1 = 1.96 + val rateValue = amountTo.value.amount / amountFrom.value.amount + TransferRateUi( + rateValueFormatted = DecimalFormat( + "###,###,##0.${"#".repeat(6)}" + ).format(rateValue), + rateValue = rateValue, + fromCurrency = amountFrom.value.currency, + toCurrency = amountTo.value.currency, + ) + } else null, + + titleSuggestions = titleSuggestions, + createFlow = createTrnController.uiFlow, + feeModal = feeModal, + rateModal = rateModal, + ) + } + }.flattenLatest() + + + // region Event Handling + override suspend fun handleEvent(event: NewTransferEvent) = when (event) { + NewTransferEvent.Initial -> handleInitial() + NewTransferEvent.Close -> handleClose() + NewTransferEvent.Add -> handleAdd() + is NewTransferEvent.TransferAmountChange -> handleTransferAmountChange(event) + is NewTransferEvent.ToAmountChange -> handleToAmountChange(event) + is NewTransferEvent.FromAmountChange -> handleFromAmountChange(event) + is NewTransferEvent.FromAccountChange -> handleFromAccountChange(event) + is NewTransferEvent.ToAccountChange -> handleToAccountChange(event) + is NewTransferEvent.FeeChange -> handleFeeChange(event) + is NewTransferEvent.FeePercent -> handleFeePercent(event) + is NewTransferEvent.TitleChange -> handleTitleChange(event) + is NewTransferEvent.DescriptionChange -> handleDescriptionChange(event) + is NewTransferEvent.CategoryChange -> handleCategoryChange(event) + is NewTransferEvent.TrnTimeChange -> handleTimeChange(event) + is NewTransferEvent.RateChange -> handleRateChange(event) + } + + private suspend fun handleInitial() { + createTrnController.startFlow() + + val accounts = accountsAct(Unit) + if (accounts.size < 2) { + // cannot do transfers with less than 2 accounts + closeScreen() + return + } + val fromAcc = accounts.first() + val toAcc = accounts[1] // 2nd + + accountFrom.value = mapAccountUiAct(fromAcc) + accountTo.value = mapAccountUiAct(toAcc) + + amountFrom.value = CombinedValueUi( + amount = 0.0, + currency = fromAcc.currency, + shortenFiat = false, + ) + fee.value = CombinedValueUi( + amount = 0.0, + currency = fromAcc.currency, + shortenFiat = false, + ) + amountTo.value = CombinedValueUi( + amount = 0.0, + currency = toAcc.currency, + shortenFiat = false, + ) + + timeUi.value = mapTrnTimeUiAct(time.value) + } + + private suspend fun handleAdd() { + val accountFrom = accountByIdAct(accountFrom.value.id) ?: return + val accountTo = accountByIdAct(accountTo.value.id) ?: return + val category = category.value?.let { categoryByIdAct(it.id) } + + val data = TransferData( + accountFrom = accountFrom, + accountTo = accountTo, + amountFrom = amountFrom.value.value, + amountTo = amountTo.value.value, + category = category, + time = time.value, + title = title.value, + description = description.value, + fee = fee.value.value.takeIf { it.amount > 0.0 }, + sync = Sync( + state = SyncState.Syncing, + lastUpdated = timeProvider.timeNow(), + ) + ) + + writeTransferAct(ModifyTransfer.add(data)) + + closeScreen() + } + + private fun handleClose() { + closeScreen() + } + + private fun closeScreen() { + createTrnController.hideKeyboard() + navigator.back() + } + + // region Handle value changes + private suspend fun handleTransferAmountChange(event: NewTransferEvent.TransferAmountChange) { + // Called initially when the transfer modal is shown + updateFromAmount(event.amount) + } + + private suspend fun handleFromAmountChange(event: NewTransferEvent.FromAmountChange) { + updateFromAmount(event.amount) + } + + private suspend fun updateFromAmount( + newFromAmount: Value + ) { + val toAccount = accountByIdAct(accountTo.value.id) ?: return + + amountFrom.value = CombinedValueUi( + value = newFromAmount, + shortenFiat = false, + ) + + val rate = uiState.value.rate + if (rate != null && rate.rateValue > 0) { + // Custom exchange rate set by the user, use it + amountTo.value = CombinedValueUi( + amount = newFromAmount.amount * rate.rateValue, + currency = toAccount.currency, + shortenFiat = false, + ) + } else { + // No rate, exchange by latest rate + amountTo.value = CombinedValueUi( + value = exchangeAct( + ExchangeAct.Input( + value = newFromAmount, + outputCurrency = toAccount.currency + ) + ), + shortenFiat = false, + ) + } + } + + private fun handleToAmountChange(event: NewTransferEvent.ToAmountChange) { + amountTo.value = CombinedValueUi( + value = event.amount, + shortenFiat = false, + ) + } + + private suspend fun handleFromAccountChange(event: NewTransferEvent.FromAccountChange) { + accountFrom.value = event.account + + accountByIdAct(event.account.id)?.let { + amountFrom.value = CombinedValueUi( + amount = amountFrom.value.value.amount, + currency = it.currency, + shortenFiat = false, + ) + fee.value = CombinedValueUi( + amount = fee.value.value.amount, + currency = it.currency, + shortenFiat = false, + ) + } + } + + private suspend fun handleToAccountChange(event: NewTransferEvent.ToAccountChange) { + accountTo.value = event.account + + accountByIdAct(event.account.id)?.let { + amountTo.value = CombinedValueUi( + amount = amountTo.value.value.amount, + currency = it.currency, + shortenFiat = false, + ) + } + } + + private fun handleFeeChange(event: NewTransferEvent.FeeChange) { + fee.value = if (event.value != null) CombinedValueUi( + value = event.value, + shortenFiat = false, + ) else { + // no fee (0 fee) + CombinedValueUi( + amount = 0.0, + currency = fee.value.value.currency, + shortenFiat = false, + ) + } + } + + private fun handleFeePercent(event: NewTransferEvent.FeePercent) { + fee.value = CombinedValueUi( + amount = amountFrom.value.value.amount * event.percent, + currency = fee.value.value.currency, + shortenFiat = false, + ) + } + + private fun handleRateChange(event: NewTransferEvent.RateChange) { + amountTo.value = CombinedValueUi( + amount = amountFrom.value.value.amount * event.newRate, + currency = amountTo.value.value.currency, + shortenFiat = false, + ) + } + + private fun handleTitleChange(event: NewTransferEvent.TitleChange) { + title.value = event.title.takeIfNotBlank() + } + + private fun handleDescriptionChange(event: NewTransferEvent.DescriptionChange) { + description.value = event.description.takeIfNotBlank() + } + + private fun handleCategoryChange(event: NewTransferEvent.CategoryChange) { + category.value = event.category + } + + private suspend fun handleTimeChange(event: NewTransferEvent.TrnTimeChange) { + time.value = event.time + timeUi.value = mapTrnTimeUiAct(event.time) + } + // endregion + // endregion +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/create/trn/NewTransactionScreen.kt b/transaction/src/main/java/com/ivy/transaction/create/trn/NewTransactionScreen.kt new file mode 100644 index 0000000..62ca9ab --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/create/trn/NewTransactionScreen.kt @@ -0,0 +1,287 @@ +package com.ivy.transaction.create.trn + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ivy.core.domain.pure.format.dummyCombinedValueUi +import com.ivy.core.domain.pure.format.dummyValueUi +import com.ivy.core.ui.category.pick.CategoryPickerModal +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.core.ui.data.dummyCategoryUi +import com.ivy.core.ui.data.transaction.dummyTrnTimeActualUi +import com.ivy.core.ui.data.transaction.dummyTrnTimeDueUi +import com.ivy.core.ui.transaction.feeling +import com.ivy.core.ui.transaction.humanText +import com.ivy.core.ui.transaction.icon +import com.ivy.data.transaction.TransactionType +import com.ivy.data.transaction.dummyTrnTimeActual +import com.ivy.data.transaction.dummyTrnTimeDue +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.keyboardPadding +import com.ivy.design.util.keyboardShownState +import com.ivy.navigation.destinations.transaction.NewTransaction +import com.ivy.resources.R +import com.ivy.transaction.component.* +import com.ivy.transaction.create.CreateTrnFlowUiState +import com.ivy.transaction.modal.DescriptionModal +import com.ivy.transaction.modal.TrnDateModal +import com.ivy.transaction.modal.TrnTimeModal +import com.ivy.transaction.modal.TrnTypeModal + +@Composable +fun BoxScope.NewTransactionScreen(arg: NewTransaction.Arg) { + val viewModel: NewTransactionViewModel = hiltViewModel() + val state by viewModel.uiState.collectAsState() + + state.createFlow.keyboardController.wire() + + LaunchedEffect(Unit) { + viewModel.onEvent(NewTrnEvent.Initial(arg)) + } + + UI( + state = state, + onEvent = viewModel::onEvent, + ) +} + +@Composable +private fun BoxScope.UI( + state: NewTrnState, + onEvent: (NewTrnEvent) -> Unit, +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + ) { + item(key = "toolbar") { + SpacerVer(height = 24.dp) + NewTrnScreenToolbar( + onClose = { + onEvent(NewTrnEvent.Close) + }, + trnType = state.trnType, + onChangeTrnType = { + state.trnTypeModal.show() + } + ) + } + item(key = "title") { + SpacerVer(height = 24.dp) + var titleFocused by remember { mutableStateOf(false) } + val keyboardShown by keyboardShownState() + TitleInput( + modifier = Modifier.onFocusChanged { + titleFocused = it.isFocused || it.hasFocus + }, + title = state.title, + focus = state.createFlow.titleFocus, + onTitleChange = { onEvent(NewTrnEvent.TitleChange(it)) }, + onCta = { onEvent(NewTrnEvent.Add) } + ) + TitleSuggestions( + focused = titleFocused && keyboardShown, + suggestions = state.titleSuggestions, + onSuggestionClick = { onEvent(NewTrnEvent.TitleChange(it)) } + ) + } + item(key = "category") { + SpacerVer(height = 12.dp) + CategoryComponent( + modifier = Modifier.padding(horizontal = 16.dp), + category = state.category + ) { + state.createFlow.categoryPickerModal.show() + } + } + item(key = "description") { + SpacerVer(height = 24.dp) + DescriptionComponent( + modifier = Modifier.padding(horizontal = 16.dp), + description = state.description + ) { + state.createFlow.descriptionModal.show() + } + } + item(key = "trn_time") { + SpacerVer(height = 12.dp) + TrnTimeComponent( + modifier = Modifier.padding(horizontal = 16.dp), + extendedTrnTime = state.timeUi, + onDateClick = { + state.createFlow.dateModal.show() + }, + onTimeClick = { + state.createFlow.timeModal.show() + } + ) + } + item(key = "last_item_spacer") { + val keyboardShown by keyboardShownState() + if (keyboardShown) { + SpacerVer(height = keyboardPadding()) + } + // To account for "Amount Account sheet" height + SpacerVer(height = 480.dp) + } + } + + AmountAccountSheet( + amountUi = state.amount.valueUi, + amount = state.amount.value, + amountBaseCurrency = state.amountBaseCurrency, + account = state.account, + ctaText = stringResource(R.string.add), + ctaIcon = R.drawable.ic_round_add_24, + accountPickerModal = state.createFlow.accountPickerModal, + amountModal = state.createFlow.amountModal, + onAccountChange = { + onEvent(NewTrnEvent.AccountChange(it)) + }, + onAmountEnter = { + onEvent(NewTrnEvent.AmountChange(it)) + }, + onCtaClick = { + onEvent(NewTrnEvent.Add) + } + ) + + Modals(state = state, onEvent = onEvent) +} + +@Composable +private fun BoxScope.Modals( + state: NewTrnState, + onEvent: (NewTrnEvent) -> Unit +) { + CategoryPickerModal( + modal = state.createFlow.categoryPickerModal, + selected = state.category, + trnType = state.trnType, + onPick = { + onEvent(NewTrnEvent.CategoryChange(it)) + } + ) + + DescriptionModal( + modal = state.createFlow.descriptionModal, + initialDescription = state.description, + onDescriptionChange = { + onEvent(NewTrnEvent.DescriptionChange(it)) + } + ) + + TrnTypeModal( + modal = state.trnTypeModal, + trnType = state.trnType, + onTransactionTypeChange = { + onEvent(NewTrnEvent.TrnTypeChange(it)) + } + ) + + TrnDateModal( + modal = state.createFlow.dateModal, + trnTime = state.time, + onTrnTimeChange = { + onEvent(NewTrnEvent.TrnTimeChange(it)) + } + ) + TrnTimeModal( + modal = state.createFlow.timeModal, + trnTime = state.time, + onTrnTimeChange = { + onEvent(NewTrnEvent.TrnTimeChange(it)) + } + ) +} + +@Composable +private fun NewTrnScreenToolbar( + onClose: () -> Unit, + trnType: TransactionType, + onChangeTrnType: () -> Unit, +) { + TrnScreenToolbar( + onClose = onClose, + actions = { + IvyButton( + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = trnType.feeling(), + text = trnType.humanText(), + icon = trnType.icon(), + onClick = onChangeTrnType, + ) + } + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview_Empty() { + IvyPreview { + UI( + state = NewTrnState( + trnType = TransactionType.Income, + category = null, + description = null, + amount = dummyCombinedValueUi(), + amountBaseCurrency = null, + account = dummyAccountUi(), + title = null, + + titleSuggestions = emptyList(), + + timeUi = dummyTrnTimeActualUi(), + time = dummyTrnTimeActual(), + trnTypeModal = rememberIvyModal(), + createFlow = CreateTrnFlowUiState.default(), + ), + onEvent = {} + ) + } +} + +@Preview +@Composable +private fun Preview_Filled() { + IvyPreview { + UI( + state = NewTrnState( + trnType = TransactionType.Expense, + title = "Tabu Shisha", + category = dummyCategoryUi(), + description = "Lorem ipsum blablablabla okay good test\n1\n2\n", + amount = dummyCombinedValueUi(amount = 23.99), + amountBaseCurrency = dummyValueUi(amount = "48.23", currency = "BGN"), + account = dummyAccountUi(), + + titleSuggestions = emptyList(), + + timeUi = dummyTrnTimeDueUi(), + time = dummyTrnTimeDue(), + trnTypeModal = rememberIvyModal(), + createFlow = CreateTrnFlowUiState.default(), + ), + onEvent = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/create/trn/NewTransactionViewModel.kt b/transaction/src/main/java/com/ivy/transaction/create/trn/NewTransactionViewModel.kt new file mode 100644 index 0000000..485ea2a --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/create/trn/NewTransactionViewModel.kt @@ -0,0 +1,277 @@ +package com.ivy.transaction.create.trn + +import com.ivy.common.isNotNullOrBlank +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.action.account.AccountByIdAct +import com.ivy.core.domain.action.category.CategoryByIdAct +import com.ivy.core.domain.action.settings.basecurrency.BaseCurrencyAct +import com.ivy.core.domain.action.transaction.WriteTrnsAct +import com.ivy.core.domain.pure.format.CombinedValueUi +import com.ivy.core.domain.pure.util.flattenLatest +import com.ivy.core.ui.action.ExchangeInBaseCurrencyFlow +import com.ivy.core.ui.action.mapping.MapCategoryUiAct +import com.ivy.core.ui.action.mapping.trn.MapTrnTimeUiAct +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.core.ui.data.transaction.TrnTimeUi +import com.ivy.data.Sync +import com.ivy.data.SyncState +import com.ivy.data.transaction.* +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.navigation.Navigator +import com.ivy.transaction.action.TitleSuggestionsFlow +import com.ivy.transaction.create.CreateTrnController +import com.ivy.transaction.create.action.PreselectedAccountAct +import com.ivy.transaction.create.action.WriteLastUsedAccount +import com.ivy.transaction.create.data.CreateTrnStep +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import java.util.* +import javax.inject.Inject + +@HiltViewModel +class NewTransactionViewModel @Inject constructor( + private val timeProvider: TimeProvider, + private val mapTrnTimeUiAct: MapTrnTimeUiAct, + private val navigator: Navigator, + private val writeTrnsAct: WriteTrnsAct, + private val accountByIdAct: AccountByIdAct, + private val categoryByIdAct: CategoryByIdAct, + private val mapCategoryUiAct: MapCategoryUiAct, + private val preselectedAccountAct: PreselectedAccountAct, + private val baseCurrencyAct: BaseCurrencyAct, + private val writeLastUsedAccount: WriteLastUsedAccount, + private val exchangeInBaseCurrencyFlow: ExchangeInBaseCurrencyFlow, + private val titleSuggestionsFlow: TitleSuggestionsFlow, + private val createTrnController: CreateTrnController, +) : SimpleFlowViewModel() { + + private val trnTypeModal = IvyModal() + + override val initialUi = NewTrnState( + trnType = TransactionType.Expense, + amount = CombinedValueUi.initial(), + amountBaseCurrency = null, + account = dummyAccountUi(), + category = null, + timeUi = TrnTimeUi.Actual("", ""), + time = TrnTime.Actual(timeProvider.timeNow()), + title = null, + description = null, + + titleSuggestions = emptyList(), + + createFlow = createTrnController.uiFlow, + trnTypeModal = trnTypeModal, + ) + + // region State + private val trnType = MutableStateFlow(initialUi.trnType) + private val amount = MutableStateFlow(initialUi.amount) + private val account = MutableStateFlow(initialUi.account) + private val category = MutableStateFlow(initialUi.category) + private val time = MutableStateFlow(TrnTime.Actual(timeProvider.timeNow())) + private val timeUi = MutableStateFlow(initialUi.timeUi) + private val title = MutableStateFlow(initialUi.title) + private val description = MutableStateFlow(initialUi.description) + // endregion + + override val uiFlow: Flow = combine( + trnType, amountFlow(), accountCategoryFlow(), textsFlow(), timeFlow(), + ) { trnType, (amount, amountBaseCurrency), (account, category), + (title, description, titleSuggestions), (time, timeUi) -> + NewTrnState( + trnType = trnType, + amount = amount, + amountBaseCurrency = amountBaseCurrency, + account = account, + category = category, + timeUi = timeUi, + time = time, + title = title, + description = description, + + titleSuggestions = titleSuggestions, + createFlow = createTrnController.uiFlow, + trnTypeModal = trnTypeModal, + ) + } + + private fun amountFlow() = amount.map { amount -> + exchangeInBaseCurrencyFlow(amount.value).map { amountBaseCurrency -> + amount to amountBaseCurrency + } + }.flattenLatest() + + private fun textsFlow() = combine( + title, description, category, + ) { title, description, category -> + titleSuggestionsFlow( + TitleSuggestionsFlow.Input( + title = title, + categoryUi = category, + transfer = false, + ) + ).map { titleSuggestions -> + Triple(title, description, titleSuggestions) + } + }.flattenLatest() + + private fun accountCategoryFlow() = combine( + account, category + ) { account, category -> + account to category + } + + private fun timeFlow() = combine( + time, timeUi + ) { time, timeUi -> + time to timeUi + } + + // region Event Handling + override suspend fun handleEvent(event: NewTrnEvent) = when (event) { + is NewTrnEvent.Initial -> handleInitial(event) + is NewTrnEvent.AccountChange -> handleAccountChange(event) + NewTrnEvent.Add -> handleAdd() + is NewTrnEvent.AmountChange -> handleAmountChange(event) + is NewTrnEvent.CategoryChange -> handleCategoryChange(event) + NewTrnEvent.Close -> handleClose() + is NewTrnEvent.TitleChange -> handleTitleChange(event) + is NewTrnEvent.DescriptionChange -> handleDescriptionChange(event) + is NewTrnEvent.TrnTimeChange -> handleTrnTimeChange(event) + is NewTrnEvent.TrnTypeChange -> handleTrnTypeChange(event) + } + + private suspend fun handleInitial(event: NewTrnEvent.Initial) { + createTrnController.startFlow() + + val arg = event.arg + trnType.value = arg.trnType + category.value = arg.categoryId?.let { + categoryByIdAct(it) + }?.let { + mapCategoryUiAct.invoke(it) + } + preselectedAccountAct( + PreselectedAccountAct.Input( + preselectedAccountId = arg.accountId + ) + )?.let { + account.value = it + } + timeUi.value = mapTrnTimeUiAct(time.value) + amount.value = CombinedValueUi( + amount = 0.0, + currency = baseCurrencyAct(Unit), + shortenFiat = false, + ) + } + + private suspend fun handleAdd() { + val account = accountByIdAct(account.value.id) ?: return + val category = category.value?.id?.let { categoryByIdAct(it) } + + val transaction = Transaction( + id = UUID.randomUUID(), + account = account, + category = category, + type = trnType.value, + value = amount.value.value, + time = time.value, + title = title.value, + description = description.value, + state = TrnState.Default, + purpose = null, + sync = Sync( + state = SyncState.Syncing, + lastUpdated = timeProvider.timeNow(), + ), + tags = emptyList(), + attachments = emptyList(), + metadata = TrnMetadata( + recurringRuleId = null, + loanId = null, + loanRecordId = null, + ) + ) + + writeTrnsAct( + WriteTrnsAct.Input.CreateNew(transaction) + ) + closeScreen() + } + + + private fun handleClose() { + closeScreen() + } + + private fun closeScreen() { + createTrnController.hideKeyboard() + navigator.back() + } + + // region Handle Value changes + private fun handleAmountChange(event: NewTrnEvent.AmountChange) { + amount.value = CombinedValueUi( + value = event.amount, + shortenFiat = false + ) + + createTrnController.nextStep(after = CreateTrnStep.Amount) + } + + private suspend fun handleAccountChange(event: NewTrnEvent.AccountChange) { + account.value = event.account + writeLastUsedAccount(WriteLastUsedAccount.Input(event.account.id)) + changeAmountToAccountCurrency(event.account) + + createTrnController.nextStep(after = CreateTrnStep.Account) + } + + private suspend fun changeAmountToAccountCurrency( + account: AccountUi + ) { + accountByIdAct(account.id)?.let { + val accountCurrency = it.currency + amount.value = CombinedValueUi( + value = amount.value.value.copy(currency = accountCurrency), + shortenFiat = false + ) + } + } + + private fun handleCategoryChange(event: NewTrnEvent.CategoryChange) { + category.value = event.category + + createTrnController.nextStep(after = CreateTrnStep.Category) + } + + private fun handleTitleChange(event: NewTrnEvent.TitleChange) { + title.value = event.title.takeIf { it.isNotBlank() } + } + + private fun handleDescriptionChange(event: NewTrnEvent.DescriptionChange) { + description.value = event.description.takeIf { it.isNotNullOrBlank() } + + createTrnController.nextStep(after = CreateTrnStep.Description) + } + + private suspend fun handleTrnTimeChange(event: NewTrnEvent.TrnTimeChange) { + time.value = event.time + timeUi.value = mapTrnTimeUiAct(event.time) + + createTrnController.nextStep(after = CreateTrnStep.Date) + } + + private fun handleTrnTypeChange(event: NewTrnEvent.TrnTypeChange) { + trnType.value = event.trnType + } + // endregion + // endregion +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/create/trn/NewTrnEvent.kt b/transaction/src/main/java/com/ivy/transaction/create/trn/NewTrnEvent.kt new file mode 100644 index 0000000..05f119f --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/create/trn/NewTrnEvent.kt @@ -0,0 +1,22 @@ +package com.ivy.transaction.create.trn + +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.data.Value +import com.ivy.data.transaction.TransactionType +import com.ivy.data.transaction.TrnTime +import com.ivy.navigation.destinations.transaction.NewTransaction + +sealed interface NewTrnEvent { + data class Initial(val arg: NewTransaction.Arg) : NewTrnEvent + object Add : NewTrnEvent + object Close : NewTrnEvent + + data class AmountChange(val amount: Value) : NewTrnEvent + data class TitleChange(val title: String) : NewTrnEvent + data class DescriptionChange(val description: String?) : NewTrnEvent + data class AccountChange(val account: AccountUi) : NewTrnEvent + data class CategoryChange(val category: CategoryUi?) : NewTrnEvent + data class TrnTypeChange(val trnType: TransactionType) : NewTrnEvent + data class TrnTimeChange(val time: TrnTime) : NewTrnEvent +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/create/trn/NewTrnState.kt b/transaction/src/main/java/com/ivy/transaction/create/trn/NewTrnState.kt new file mode 100644 index 0000000..309bc9d --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/create/trn/NewTrnState.kt @@ -0,0 +1,32 @@ +package com.ivy.transaction.create.trn + +import androidx.compose.runtime.Immutable +import com.ivy.core.domain.pure.format.CombinedValueUi +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.transaction.TrnTimeUi +import com.ivy.data.transaction.TransactionType +import com.ivy.data.transaction.TrnTime +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.transaction.create.CreateTrnFlowUiState + +@Immutable +data class NewTrnState( + val trnType: TransactionType, + val amount: CombinedValueUi, + val amountBaseCurrency: ValueUi?, + val account: AccountUi, + val category: CategoryUi?, + val timeUi: TrnTimeUi, + val time: TrnTime, + val title: String?, + val description: String?, + + val titleSuggestions: List, + + // region Create flow + val createFlow: CreateTrnFlowUiState, + val trnTypeModal: IvyModal, + // endregion +) \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/data/TransferRateUi.kt b/transaction/src/main/java/com/ivy/transaction/data/TransferRateUi.kt new file mode 100644 index 0000000..041c121 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/data/TransferRateUi.kt @@ -0,0 +1,12 @@ +package com.ivy.transaction.data + +import androidx.compose.runtime.Immutable +import com.ivy.data.CurrencyCode + +@Immutable +data class TransferRateUi( + val rateValue: Double, + val rateValueFormatted: String, + val fromCurrency: CurrencyCode, + val toCurrency: CurrencyCode, +) \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/edit/transfer/EditTransferEvent.kt b/transaction/src/main/java/com/ivy/transaction/edit/transfer/EditTransferEvent.kt new file mode 100644 index 0000000..1565f73 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/edit/transfer/EditTransferEvent.kt @@ -0,0 +1,25 @@ +package com.ivy.transaction.edit.transfer + +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.data.Value +import com.ivy.data.transaction.TrnTime + +sealed interface EditTransferEvent { + data class Initial(val batchId: String) : EditTransferEvent + object Save : EditTransferEvent + object Close : EditTransferEvent + object Delete : EditTransferEvent + + data class FromAmountChange(val amount: Value) : EditTransferEvent + data class ToAmountChange(val amount: Value) : EditTransferEvent + data class TitleChange(val title: String) : EditTransferEvent + data class DescriptionChange(val description: String?) : EditTransferEvent + data class FromAccountChange(val account: AccountUi) : EditTransferEvent + data class ToAccountChange(val account: AccountUi) : EditTransferEvent + data class CategoryChange(val category: CategoryUi?) : EditTransferEvent + data class TrnTimeChange(val time: TrnTime) : EditTransferEvent + data class FeeChange(val value: Value?) : EditTransferEvent + data class FeePercent(val percent: Double) : EditTransferEvent + data class RateChange(val newRate: Double) : EditTransferEvent +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/edit/transfer/EditTransferScreen.kt b/transaction/src/main/java/com/ivy/transaction/edit/transfer/EditTransferScreen.kt new file mode 100644 index 0000000..a769ec8 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/edit/transfer/EditTransferScreen.kt @@ -0,0 +1,350 @@ +package com.ivy.transaction.edit.transfer + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ivy.core.domain.pure.dummy.dummyActual +import com.ivy.core.domain.pure.format.dummyCombinedValueUi +import com.ivy.core.ui.category.pick.CategoryPickerModal +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.core.ui.data.dummyCategoryUi +import com.ivy.core.ui.data.transaction.dummyTrnTimeActualUi +import com.ivy.core.ui.modal.RateModal +import com.ivy.design.l0_system.color.Blue2Dark +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.button.DeleteButton +import com.ivy.design.l3_ivyComponents.modal.DeleteConfirmationModal +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.KeyboardController +import com.ivy.design.util.keyboardPadding +import com.ivy.design.util.keyboardShownState +import com.ivy.resources.R +import com.ivy.transaction.component.* +import com.ivy.transaction.data.TransferRateUi +import com.ivy.transaction.modal.DescriptionModal +import com.ivy.transaction.modal.FeeModal +import com.ivy.transaction.modal.TrnDateModal +import com.ivy.transaction.modal.TrnTimeModal + +@Composable +fun BoxScope.EditTransferScreen( + batchId: String, +) { + val viewModel: EditTransferViewModel = hiltViewModel() + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.onEvent(EditTransferEvent.Initial(batchId = batchId)) + } + + UI(state = state, onEvent = viewModel::onEvent) +} + +@Composable +private fun BoxScope.UI( + state: EditTransferState, + onEvent: (EditTransferEvent) -> Unit, +) { + val dateModal = rememberIvyModal() + val timeModal = rememberIvyModal() + val categoryPickerModal = rememberIvyModal() + val descriptionModal = rememberIvyModal() + val deleteConfirmationModal = rememberIvyModal() + val feeModal = rememberIvyModal() + val rateModal = rememberIvyModal() + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + ) { + item(key = "toolbar") { + SpacerVer(height = 24.dp) + TrnScreenToolbar( + onClose = { + onEvent(EditTransferEvent.Close) + }, + actions = {}, + ) + } + item(key = "title") { + SpacerVer(height = 24.dp) + val titleFocus = remember { FocusRequester() } + var titleFocused by remember { mutableStateOf(false) } + val keyboardShown by keyboardShownState() + TitleInput( + modifier = Modifier.onFocusChanged { + titleFocused = it.isFocused || it.hasFocus + }, + title = state.title, + focus = titleFocus, + onTitleChange = { onEvent(EditTransferEvent.TitleChange(it)) }, + onCta = { onEvent(EditTransferEvent.Save) } + ) + TitleSuggestions( + focused = titleFocused && keyboardShown, + suggestions = state.titleSuggestions, + onSuggestionClick = { onEvent(EditTransferEvent.TitleChange(it)) } + ) + } + item(key = "category") { + SpacerVer(height = 12.dp) + CategoryComponent( + modifier = Modifier.padding(horizontal = 16.dp), + category = state.category + ) { + categoryPickerModal.show() + } + } + item(key = "description") { + SpacerVer(height = 24.dp) + DescriptionComponent( + modifier = Modifier.padding(horizontal = 16.dp), + description = state.description + ) { + descriptionModal.show() + } + } + item(key = "trn_time") { + SpacerVer(height = 12.dp) + TrnTimeComponent( + modifier = Modifier.padding(horizontal = 16.dp), + extendedTrnTime = state.timeUi, + onTimeClick = { + timeModal.show() + }, + onDateClick = { + dateModal.show() + } + ) + } + item(key = "fee_rate") { + SpacerVer(height = 12.dp) + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + FeeComponent( + fee = state.fee.valueUi, + validFee = state.fee.value.amount > 0 + ) { + feeModal.show() + } + if (state.rate != null) { + SpacerHor(width = 12.dp) + TransferRateComponent( + modifier = Modifier.weight(1f), + rate = state.rate, + ) { + rateModal.show() + } + } + } + } + item(key = "last_item_spacer") { + val keyboardShown by keyboardShownState() + if (keyboardShown) { + SpacerVer(height = keyboardPadding()) + } + // To account for bottom sheet's height + SpacerVer(height = 520.dp) + } + } + + TransferBottomSheet( + accountFrom = state.accountFrom, + amountFromUi = state.amountFrom.valueUi, + amountFrom = state.amountFrom.value, + accountTo = state.accountTo, + amountToUi = state.amountTo.valueUi, + amountTo = state.amountTo.value, + ctaText = stringResource(R.string.save), + ctaIcon = R.drawable.round_done_24, + secondaryActions = { + DeleteButton { + deleteConfirmationModal.show() + } + SpacerHor(width = 12.dp) + }, + onCtaClick = { + onEvent(EditTransferEvent.Save) + }, + onFromAccountChange = { + onEvent(EditTransferEvent.FromAccountChange(it)) + }, + onToAccountChange = { + onEvent(EditTransferEvent.ToAccountChange(it)) + }, + onFromAmountChange = { + onEvent(EditTransferEvent.FromAmountChange(it)) + }, + onToAmountChange = { + onEvent(EditTransferEvent.ToAmountChange(it)) + }, + ) + + Modals( + state = state, + dateModal = dateModal, + timeModal = timeModal, + descriptionModal = descriptionModal, + categoryPickerModal = categoryPickerModal, + deleteConfirmationModal = deleteConfirmationModal, + feeModal = feeModal, + rateModal = rateModal, + onEvent = onEvent + ) +} + +@Composable +private fun BoxScope.Modals( + state: EditTransferState, + dateModal: IvyModal, + timeModal: IvyModal, + descriptionModal: IvyModal, + categoryPickerModal: IvyModal, + deleteConfirmationModal: IvyModal, + feeModal: IvyModal, + rateModal: IvyModal, + onEvent: (EditTransferEvent) -> Unit +) { + CategoryPickerModal( + modal = categoryPickerModal, + selected = state.category, + trnType = null, + onPick = { + onEvent(EditTransferEvent.CategoryChange(it)) + } + ) + + DescriptionModal( + modal = descriptionModal, + initialDescription = state.description, + onDescriptionChange = { + onEvent(EditTransferEvent.DescriptionChange(it)) + } + ) + + TrnDateModal( + modal = dateModal, + trnTime = state.time, + onTrnTimeChange = { + onEvent(EditTransferEvent.TrnTimeChange(it)) + } + ) + TrnTimeModal( + modal = timeModal, + trnTime = state.time, + onTrnTimeChange = { + onEvent(EditTransferEvent.TrnTimeChange(it)) + } + ) + + // Fee modal + FeeModal( + modal = feeModal, + fee = state.fee.value, + onRemoveFee = { + onEvent(EditTransferEvent.FeeChange(null)) + }, + onFeePercent = { + onEvent(EditTransferEvent.FeePercent(it)) + }, + onFeeChange = { + onEvent(EditTransferEvent.FeeChange(it)) + } + ) + + if (state.rate != null) { + RateModal( + modal = rateModal, + key = "transfer_rate", + rate = state.rate.rateValue, + fromCurrency = state.rate.fromCurrency, + toCurrency = state.rate.toCurrency, + onRateChange = { + onEvent(EditTransferEvent.RateChange(it)) + } + ) + } + + DeleteConfirmationModal(modal = deleteConfirmationModal) { + onEvent(EditTransferEvent.Delete) + } +} + +// region Previews +@Preview +@Composable +private fun Preview() { + IvyPreview { + UI( + state = EditTransferState( + accountFrom = dummyAccountUi( + name = "Personal Bank", + color = Blue2Dark, + ), + amountFrom = dummyCombinedValueUi(), + accountTo = dummyAccountUi(name = "Cash"), + amountTo = dummyCombinedValueUi(), + category = dummyCategoryUi(), + description = null, + timeUi = dummyTrnTimeActualUi(), + time = dummyActual(), + title = null, + fee = dummyCombinedValueUi(), + rate = TransferRateUi( + rateValueFormatted = "1.96", + rateValue = 1.95583, + fromCurrency = "EUR", + toCurrency = "BGN" + ), + + titleSuggestions = listOf("Title 1", "Title 2"), + keyboardController = KeyboardController(), + ), + onEvent = {} + ) + } +} + +@Preview +@Composable +private fun Preview_Filled() { + IvyPreview { + UI( + state = EditTransferState( + accountFrom = dummyAccountUi( + name = "Personal Bank", + color = Blue2Dark, + ), + amountFrom = dummyCombinedValueUi(amount = 400.0), + accountTo = dummyAccountUi(name = "Cash"), + amountTo = dummyCombinedValueUi(amount = 400.0), + category = dummyCategoryUi(), + description = "Need some cash", + timeUi = dummyTrnTimeActualUi(), + time = dummyActual(), + title = "ATM Withdrawal", + fee = dummyCombinedValueUi(amount = 2.0), + rate = null, + + titleSuggestions = listOf("Title 1", "Title 2"), + keyboardController = KeyboardController(), + ), + onEvent = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/edit/transfer/EditTransferState.kt b/transaction/src/main/java/com/ivy/transaction/edit/transfer/EditTransferState.kt new file mode 100644 index 0000000..2120a0b --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/edit/transfer/EditTransferState.kt @@ -0,0 +1,30 @@ +package com.ivy.transaction.edit.transfer + +import androidx.compose.runtime.Immutable +import com.ivy.core.domain.pure.format.CombinedValueUi +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.transaction.TrnTimeUi +import com.ivy.data.transaction.TrnTime +import com.ivy.design.util.KeyboardController +import com.ivy.transaction.data.TransferRateUi + +@Immutable +data class EditTransferState( + val accountFrom: AccountUi, + val accountTo: AccountUi, + val amountFrom: CombinedValueUi, + val amountTo: CombinedValueUi, + + val category: CategoryUi?, + val timeUi: TrnTimeUi, + val time: TrnTime, + val title: String?, + val description: String?, + val fee: CombinedValueUi, + val rate: TransferRateUi?, + + val titleSuggestions: List, + + val keyboardController: KeyboardController, +) \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/edit/transfer/EditTransferViewModel.kt b/transaction/src/main/java/com/ivy/transaction/edit/transfer/EditTransferViewModel.kt new file mode 100644 index 0000000..e21cc2a --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/edit/transfer/EditTransferViewModel.kt @@ -0,0 +1,362 @@ +package com.ivy.transaction.edit.transfer + +import com.ivy.common.time.provider.TimeProvider +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.action.account.AccountByIdAct +import com.ivy.core.domain.action.category.CategoryByIdAct +import com.ivy.core.domain.action.exchange.ExchangeAct +import com.ivy.core.domain.action.transaction.transfer.ModifyTransfer +import com.ivy.core.domain.action.transaction.transfer.TransferByBatchIdAct +import com.ivy.core.domain.action.transaction.transfer.TransferData +import com.ivy.core.domain.action.transaction.transfer.WriteTransferAct +import com.ivy.core.domain.pure.format.CombinedValueUi +import com.ivy.core.domain.pure.util.combine +import com.ivy.core.domain.pure.util.flattenLatest +import com.ivy.core.domain.pure.util.takeIfNotBlank +import com.ivy.core.ui.action.mapping.MapCategoryUiAct +import com.ivy.core.ui.action.mapping.account.MapAccountUiAct +import com.ivy.core.ui.action.mapping.trn.MapTrnTimeUiAct +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.core.ui.data.transaction.TrnTimeUi +import com.ivy.data.Sync +import com.ivy.data.SyncState +import com.ivy.data.Value +import com.ivy.data.transaction.Transfer +import com.ivy.data.transaction.TrnTime +import com.ivy.design.util.KeyboardController +import com.ivy.navigation.Navigator +import com.ivy.transaction.action.TitleSuggestionsFlow +import com.ivy.transaction.data.TransferRateUi +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import java.text.DecimalFormat +import javax.inject.Inject + +@HiltViewModel +class EditTransferViewModel @Inject constructor( + private val timeProvider: TimeProvider, + private val titleSuggestionsFlow: TitleSuggestionsFlow, + private val mapTrnTimeUiAct: MapTrnTimeUiAct, + private val navigator: Navigator, + private val accountByIdAct: AccountByIdAct, + private val categoryByIdAct: CategoryByIdAct, + private val mapCategoryUiAct: MapCategoryUiAct, + private val mapAccountUiAct: MapAccountUiAct, + private val writeTransferAct: WriteTransferAct, + private val exchangeAct: ExchangeAct, + private val transferByBatchIdAct: TransferByBatchIdAct, +) : SimpleFlowViewModel() { + + private val keyboardController = KeyboardController() + + override val initialUi = EditTransferState( + accountFrom = dummyAccountUi(), + accountTo = dummyAccountUi(), + amountFrom = CombinedValueUi.initial(), + amountTo = CombinedValueUi.initial(), + category = null, + timeUi = TrnTimeUi.Actual("", ""), + time = TrnTime.Actual(timeProvider.timeNow()), + title = null, + description = null, + fee = CombinedValueUi.initial(), + rate = null, + + titleSuggestions = emptyList(), + + keyboardController = keyboardController, + ) + + // region State + private val amountFrom = MutableStateFlow(initialUi.amountFrom) + private val amountTo = MutableStateFlow(initialUi.amountTo) + private val accountFrom = MutableStateFlow(initialUi.accountFrom) + private val accountTo = MutableStateFlow(initialUi.accountTo) + private val category = MutableStateFlow(initialUi.category) + private val time = MutableStateFlow(TrnTime.Actual(timeProvider.timeNow())) + private val timeUi = MutableStateFlow(initialUi.timeUi) + private val title = MutableStateFlow(initialUi.title) + private val description = MutableStateFlow(initialUi.description) + private val fee = MutableStateFlow(initialUi.fee) + // endregion + + private lateinit var transfer: Transfer + + override val uiFlow = combine( + amountFrom, amountTo, + accountFrom, accountTo, category, time, timeUi, + title, description, fee, + ) + { amountFrom, amountTo, + accountFrom, accountTo, category, time, timeUi, + title, description, fee -> + titleSuggestionsFlow( + TitleSuggestionsFlow.Input( + title = title, + categoryUi = category, + transfer = true, + ) + ).map { titleSuggestions -> + EditTransferState( + amountFrom = amountFrom, + amountTo = amountTo, + accountFrom = accountFrom, + accountTo = accountTo, + category = category, + time = time, + timeUi = timeUi, + title = title, + description = description, + fee = fee, + rate = if (amountFrom.value.currency != amountTo.value.currency && + amountFrom.value.amount > 0.0 && amountTo.value.amount > 0.0 + ) { + // e.g. 1 EUR to 1.96 BGN + // => EUR-BGN = 1.96 / 1 = 1.96 + val rateValue = amountTo.value.amount / amountFrom.value.amount + TransferRateUi( + rateValueFormatted = DecimalFormat( + "###,###,##0.${"#".repeat(6)}" + ).format(rateValue), + rateValue = rateValue, + fromCurrency = amountFrom.value.currency, + toCurrency = amountTo.value.currency, + ) + } else null, + + titleSuggestions = titleSuggestions, + + keyboardController = keyboardController, + ) + } + }.flattenLatest() + + + // region Event Handling + override suspend fun handleEvent(event: EditTransferEvent) = when (event) { + is EditTransferEvent.Initial -> handleInitial(event) + EditTransferEvent.Save -> handleSave() + EditTransferEvent.Delete -> handleDelete() + EditTransferEvent.Close -> handleClose() + is EditTransferEvent.ToAmountChange -> handleToAmountChange(event) + is EditTransferEvent.FromAmountChange -> handleFromAmountChange(event) + is EditTransferEvent.FromAccountChange -> handleFromAccountChange(event) + is EditTransferEvent.ToAccountChange -> handleToAccountChange(event) + is EditTransferEvent.FeeChange -> handleFeeChange(event) + is EditTransferEvent.FeePercent -> handleFeePercent(event) + is EditTransferEvent.TitleChange -> handleTitleChange(event) + is EditTransferEvent.DescriptionChange -> handleDescriptionChange(event) + is EditTransferEvent.CategoryChange -> handleCategoryChange(event) + is EditTransferEvent.TrnTimeChange -> handleTimeChange(event) + is EditTransferEvent.RateChange -> handleRateChange(event) + } + + private suspend fun handleInitial(event: EditTransferEvent.Initial) { + val transfer = transferByBatchIdAct(event.batchId)?.also { + this.transfer = it + } + + if (transfer == null) { + closeScreen() + return + } + + // Init UI state + amountFrom.value = CombinedValueUi( + value = transfer.from.value, + shortenFiat = false, + ) + accountFrom.value = mapAccountUiAct(transfer.from.account) + + amountTo.value = CombinedValueUi( + value = transfer.to.value, + shortenFiat = false, + ) + accountTo.value = mapAccountUiAct(transfer.to.account) + + category.value = transfer.from.category?.let { mapCategoryUiAct(it) } + time.value = transfer.time + timeUi.value = mapTrnTimeUiAct(transfer.time) + title.value = transfer.from.title + description.value = transfer.from.description + fee.value = transfer.fee?.value?.let { + CombinedValueUi( + value = it, + shortenFiat = false, + ) + } ?: CombinedValueUi( + amount = 0.0, // no fee + currency = transfer.from.value.currency, + shortenFiat = false, + ) + } + + private suspend fun handleSave() { + val accountFrom = accountByIdAct(accountFrom.value.id) ?: return + val accountTo = accountByIdAct(accountTo.value.id) ?: return + val category = category.value?.let { categoryByIdAct(it.id) } + + val data = TransferData( + accountFrom = accountFrom, + accountTo = accountTo, + amountFrom = amountFrom.value.value, + amountTo = amountTo.value.value, + category = category, + time = time.value, + title = title.value, + description = description.value, + fee = fee.value.value.takeIf { it.amount > 0.0 }, + sync = Sync( + state = SyncState.Syncing, + lastUpdated = timeProvider.timeNow(), + ) + ) + + writeTransferAct( + ModifyTransfer.edit( + batchId = transfer.batchId, + data = data + ) + ) + + closeScreen() + } + + private suspend fun handleDelete() { + writeTransferAct(ModifyTransfer.delete(transfer = transfer)) + closeScreen() + } + + private fun handleClose() { + closeScreen() + } + + private fun closeScreen() { + keyboardController.hide() + navigator.back() + } + + // region Handle value changes + + private suspend fun handleFromAmountChange(event: EditTransferEvent.FromAmountChange) { + updateFromAmount(event.amount) + } + + private suspend fun updateFromAmount( + newFromAmount: Value + ) { + val toAccount = accountByIdAct(accountTo.value.id) ?: return + + amountFrom.value = CombinedValueUi( + value = newFromAmount, + shortenFiat = false, + ) + + val rate = uiState.value.rate + if (rate != null && rate.rateValue > 0) { + // Custom exchange rate set by the user, use it + amountTo.value = CombinedValueUi( + amount = newFromAmount.amount * rate.rateValue, + currency = toAccount.currency, + shortenFiat = false, + ) + } else { + // No rate, exchange by latest rate + amountTo.value = CombinedValueUi( + value = exchangeAct( + ExchangeAct.Input( + value = newFromAmount, + outputCurrency = toAccount.currency + ) + ), + shortenFiat = false, + ) + } + } + + private fun handleToAmountChange(event: EditTransferEvent.ToAmountChange) { + amountTo.value = CombinedValueUi( + value = event.amount, + shortenFiat = false, + ) + } + + private suspend fun handleFromAccountChange(event: EditTransferEvent.FromAccountChange) { + accountFrom.value = event.account + + accountByIdAct(event.account.id)?.let { + amountFrom.value = CombinedValueUi( + amount = amountFrom.value.value.amount, + currency = it.currency, + shortenFiat = false, + ) + fee.value = CombinedValueUi( + amount = fee.value.value.amount, + currency = it.currency, + shortenFiat = false, + ) + } + } + + private suspend fun handleToAccountChange(event: EditTransferEvent.ToAccountChange) { + accountTo.value = event.account + + accountByIdAct(event.account.id)?.let { + amountTo.value = CombinedValueUi( + amount = amountTo.value.value.amount, + currency = it.currency, + shortenFiat = false, + ) + } + } + + private fun handleFeeChange(event: EditTransferEvent.FeeChange) { + fee.value = if (event.value != null) CombinedValueUi( + value = event.value, + shortenFiat = false, + ) else { + // no fee (0 fee) + CombinedValueUi( + amount = 0.0, + currency = fee.value.value.currency, + shortenFiat = false, + ) + } + } + + private fun handleFeePercent(event: EditTransferEvent.FeePercent) { + fee.value = CombinedValueUi( + amount = amountFrom.value.value.amount * event.percent, + currency = fee.value.value.currency, + shortenFiat = false, + ) + } + + private fun handleRateChange(event: EditTransferEvent.RateChange) { + amountTo.value = CombinedValueUi( + amount = amountFrom.value.value.amount * event.newRate, + currency = amountTo.value.value.currency, + shortenFiat = false, + ) + } + + private fun handleTitleChange(event: EditTransferEvent.TitleChange) { + title.value = event.title.takeIfNotBlank() + } + + private fun handleDescriptionChange(event: EditTransferEvent.DescriptionChange) { + description.value = event.description.takeIfNotBlank() + } + + private fun handleCategoryChange(event: EditTransferEvent.CategoryChange) { + category.value = event.category + } + + private suspend fun handleTimeChange(event: EditTransferEvent.TrnTimeChange) { + time.value = event.time + timeUi.value = mapTrnTimeUiAct(event.time) + } + // endregion + // endregion +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/edit/trn/EditTransactionScreen.kt b/transaction/src/main/java/com/ivy/transaction/edit/trn/EditTransactionScreen.kt new file mode 100644 index 0000000..c1c7ff5 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/edit/trn/EditTransactionScreen.kt @@ -0,0 +1,334 @@ +package com.ivy.transaction.edit.trn + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ivy.core.domain.pure.format.dummyCombinedValueUi +import com.ivy.core.domain.pure.format.dummyValueUi +import com.ivy.core.ui.category.pick.CategoryPickerModal +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.core.ui.data.dummyCategoryUi +import com.ivy.core.ui.data.transaction.dummyTrnTimeActualUi +import com.ivy.core.ui.data.transaction.dummyTrnTimeDueUi +import com.ivy.core.ui.transaction.feeling +import com.ivy.core.ui.transaction.humanText +import com.ivy.core.ui.transaction.icon +import com.ivy.data.transaction.TransactionType +import com.ivy.data.transaction.dummyTrnTimeActual +import com.ivy.data.transaction.dummyTrnTimeDue +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.SwitchRow +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.rememberIvyModal +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.DeleteButton +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.l3_ivyComponents.modal.DeleteConfirmationModal +import com.ivy.design.util.IvyPreview +import com.ivy.design.util.KeyboardController +import com.ivy.design.util.keyboardPadding +import com.ivy.design.util.keyboardShownState +import com.ivy.resources.R +import com.ivy.transaction.component.* +import com.ivy.transaction.modal.DescriptionModal +import com.ivy.transaction.modal.TrnDateModal +import com.ivy.transaction.modal.TrnTimeModal +import com.ivy.transaction.modal.TrnTypeModal + +@Composable +fun BoxScope.EditTransactionScreen(trnId: String) { + val viewModel: EditTransactionViewModel = hiltViewModel() + val state by viewModel.uiState.collectAsState() + + state.keyboardController.wire() + + LaunchedEffect(Unit) { + viewModel.onEvent(EditTrnEvent.Initial(trnId = trnId)) + } + + UI( + state = state, + onEvent = viewModel::onEvent, + ) +} + +@Composable +private fun BoxScope.UI( + state: EditTrnState, + onEvent: (EditTrnEvent) -> Unit, +) { + val trnTypeModal = rememberIvyModal() + val dateModal = rememberIvyModal() + val timeModal = rememberIvyModal() + val accountPickerModal = rememberIvyModal() + val categoryPickerModal = rememberIvyModal() + val descriptionModal = rememberIvyModal() + val amountModal = rememberIvyModal() + val deleteConfirmationModal = rememberIvyModal() + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + ) { + item(key = "toolbar") { + SpacerVer(height = 24.dp) + EditTrnScreenToolbar( + onClose = { + onEvent(EditTrnEvent.Close) + }, + trnType = state.trnType, + onChangeTrnType = { + trnTypeModal.show() + } + ) + } + item(key = "title") { + SpacerVer(height = 24.dp) + var titleFocused by remember { mutableStateOf(false) } + val keyboardShown by keyboardShownState() + TitleInput( + modifier = Modifier.onFocusChanged { + titleFocused = it.isFocused || it.hasFocus + }, + title = state.title, + focus = remember { FocusRequester() }, + onTitleChange = { onEvent(EditTrnEvent.TitleChange(it)) }, + onCta = { onEvent(EditTrnEvent.Save) } + ) + TitleSuggestions( + focused = titleFocused && keyboardShown, + suggestions = state.titleSuggestions, + onSuggestionClick = { onEvent(EditTrnEvent.TitleChange(it)) } + ) + } + item(key = "category") { + SpacerVer(height = 12.dp) + CategoryComponent( + modifier = Modifier.padding(horizontal = 16.dp), + category = state.category + ) { + categoryPickerModal.show() + } + } + item(key = "description") { + SpacerVer(height = 24.dp) + DescriptionComponent( + modifier = Modifier.padding(horizontal = 16.dp), + description = state.description + ) { + descriptionModal.show() + } + } + item(key = "trn_time") { + SpacerVer(height = 12.dp) + TrnTimeComponent( + modifier = Modifier.padding(horizontal = 16.dp), + extendedTrnTime = state.timeUi, + onDateClick = { dateModal.show() }, + onTimeClick = { timeModal.show() } + ) + } + item(key = "hidden_switch") { + SpacerVer(height = 12.dp) + SwitchRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .border(2.dp, UI.colors.primary, UI.shapes.fullyRounded), + enabled = state.hidden, + text = "Hide transaction", + onValueChange = { + onEvent(EditTrnEvent.HiddenChange(it)) + } + ) + } + item(key = "last_item_spacer") { + val keyboardShown by keyboardShownState() + if (keyboardShown) { + SpacerVer(height = keyboardPadding()) + } + // To account for "Amount Account sheet" height + SpacerVer(height = 480.dp) + } + } + + AmountAccountSheet( + amountUi = state.amount.valueUi, + amount = state.amount.value, + amountBaseCurrency = state.amountBaseCurrency, + account = state.account, + ctaText = stringResource(R.string.save), + ctaIcon = R.drawable.round_done_24, + accountPickerModal = accountPickerModal, + amountModal = amountModal, + secondaryActions = { + DeleteButton { + deleteConfirmationModal.show() + } + SpacerHor(width = 12.dp) + }, + onAccountChange = { + onEvent(EditTrnEvent.AccountChange(it)) + }, + onAmountEnter = { + onEvent(EditTrnEvent.AmountChange(it)) + }, + onCtaClick = { + onEvent(EditTrnEvent.Save) + } + ) + + Modals( + state = state, + trnTypeModal = trnTypeModal, + dateModal = dateModal, + timeModal = timeModal, + descriptionModal = descriptionModal, + categoryPickerModal = categoryPickerModal, + deleteConfirmationModal = deleteConfirmationModal, + onEvent = onEvent + ) +} + +@Composable +private fun BoxScope.Modals( + state: EditTrnState, + categoryPickerModal: IvyModal, + descriptionModal: IvyModal, + trnTypeModal: IvyModal, + dateModal: IvyModal, + timeModal: IvyModal, + deleteConfirmationModal: IvyModal, + onEvent: (EditTrnEvent) -> Unit +) { + CategoryPickerModal( + modal = categoryPickerModal, + selected = state.category, + trnType = state.trnType, + onPick = { + onEvent(EditTrnEvent.CategoryChange(it)) + } + ) + + DescriptionModal( + modal = descriptionModal, + initialDescription = state.description, + onDescriptionChange = { + onEvent(EditTrnEvent.DescriptionChange(it)) + } + ) + + TrnTypeModal( + modal = trnTypeModal, + trnType = state.trnType, + onTransactionTypeChange = { + onEvent(EditTrnEvent.TrnTypeChange(it)) + } + ) + + TrnDateModal( + modal = dateModal, + trnTime = state.time, + onTrnTimeChange = { + onEvent(EditTrnEvent.TrnTimeChange(it)) + } + ) + TrnTimeModal( + modal = timeModal, + trnTime = state.time, + onTrnTimeChange = { + onEvent(EditTrnEvent.TrnTimeChange(it)) + } + ) + + DeleteConfirmationModal(modal = deleteConfirmationModal) { + onEvent(EditTrnEvent.Delete) + } +} + +@Composable +private fun EditTrnScreenToolbar( + onClose: () -> Unit, + trnType: TransactionType, + onChangeTrnType: () -> Unit, +) { + TrnScreenToolbar( + onClose = onClose, + actions = { + IvyButton( + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = trnType.feeling(), + text = trnType.humanText(), + icon = trnType.icon(), + onClick = onChangeTrnType, + ) + } + ) +} + + +// region Preview +@Preview +@Composable +private fun Preview_Empty() { + IvyPreview { + UI( + state = EditTrnState( + trnType = TransactionType.Income, + category = null, + description = null, + amount = dummyCombinedValueUi(), + amountBaseCurrency = null, + account = dummyAccountUi(), + title = null, + hidden = false, + + keyboardController = KeyboardController(), + titleSuggestions = emptyList(), + timeUi = dummyTrnTimeActualUi(), + time = dummyTrnTimeActual(), + ), + onEvent = {} + ) + } +} + +@Preview +@Composable +private fun Preview_Filled() { + IvyPreview { + UI( + state = EditTrnState( + trnType = TransactionType.Expense, + title = "Tabu Shisha", + category = dummyCategoryUi(), + description = "Lorem ipsum blablablabla okay good test\n1\n2\n", + amount = dummyCombinedValueUi(amount = 23.99), + amountBaseCurrency = dummyValueUi(amount = "48.23", currency = "BGN"), + account = dummyAccountUi(), + hidden = true, + + titleSuggestions = emptyList(), + keyboardController = KeyboardController(), + + timeUi = dummyTrnTimeDueUi(), + time = dummyTrnTimeDue(), + ), + onEvent = {} + ) + } +} +// endregion \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/edit/trn/EditTransactionViewModel.kt b/transaction/src/main/java/com/ivy/transaction/edit/trn/EditTransactionViewModel.kt new file mode 100644 index 0000000..99e75f6 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/edit/trn/EditTransactionViewModel.kt @@ -0,0 +1,268 @@ +package com.ivy.transaction.edit.trn + +import com.ivy.common.isNotEmpty +import com.ivy.common.isNotNullOrBlank +import com.ivy.common.time.provider.TimeProvider +import com.ivy.common.toUUIDOrNull +import com.ivy.core.domain.SimpleFlowViewModel +import com.ivy.core.domain.action.account.AccountByIdAct +import com.ivy.core.domain.action.category.CategoryByIdAct +import com.ivy.core.domain.action.transaction.TrnByIdAct +import com.ivy.core.domain.action.transaction.WriteTrnsAct +import com.ivy.core.domain.pure.format.CombinedValueUi +import com.ivy.core.domain.pure.util.flattenLatest +import com.ivy.core.ui.action.ExchangeInBaseCurrencyFlow +import com.ivy.core.ui.action.mapping.MapCategoryUiAct +import com.ivy.core.ui.action.mapping.account.MapAccountUiAct +import com.ivy.core.ui.action.mapping.trn.MapTrnTimeUiAct +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.core.ui.data.transaction.TrnTimeUi +import com.ivy.data.Sync +import com.ivy.data.SyncState +import com.ivy.data.transaction.Transaction +import com.ivy.data.transaction.TransactionType +import com.ivy.data.transaction.TrnState +import com.ivy.data.transaction.TrnTime +import com.ivy.design.util.KeyboardController +import com.ivy.navigation.Navigator +import com.ivy.transaction.action.TitleSuggestionsFlow +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@HiltViewModel +class EditTransactionViewModel @Inject constructor( + private val timeProvider: TimeProvider, + private val mapTrnTimeUiAct: MapTrnTimeUiAct, + private val navigator: Navigator, + private val writeTrnsAct: WriteTrnsAct, + private val accountByIdAct: AccountByIdAct, + private val categoryByIdAct: CategoryByIdAct, + private val mapCategoryUiAct: MapCategoryUiAct, + private val exchangeInBaseCurrencyFlow: ExchangeInBaseCurrencyFlow, + private val titleSuggestionsFlow: TitleSuggestionsFlow, + private val trnByIdAct: TrnByIdAct, + private val mapAccountUiAct: MapAccountUiAct, +) : SimpleFlowViewModel() { + + private val keyboardController = KeyboardController() + + override val initialUi = EditTrnState( + trnType = TransactionType.Expense, + amount = CombinedValueUi.initial(), + amountBaseCurrency = null, + account = dummyAccountUi(), + category = null, + timeUi = TrnTimeUi.Actual("", ""), + time = TrnTime.Actual(timeProvider.timeNow()), + title = null, + description = null, + hidden = false, + + titleSuggestions = emptyList(), + keyboardController = keyboardController, + ) + + private var transaction: Transaction? = null + + // region State + private val trnType = MutableStateFlow(initialUi.trnType) + private val amount = MutableStateFlow(initialUi.amount) + private val account = MutableStateFlow(initialUi.account) + private val category = MutableStateFlow(initialUi.category) + private val time = MutableStateFlow(TrnTime.Actual(timeProvider.timeNow())) + private val timeUi = MutableStateFlow(initialUi.timeUi) + private val title = MutableStateFlow(initialUi.title) + private val description = MutableStateFlow(initialUi.description) + private val hidden = MutableStateFlow(false) + // endregion + + override val uiFlow: Flow = combine( + trnType, amountFlow(), accountCategoryFlow(), textsFlow(), othersFlow(), + ) { trnType, (amount, amountBaseCurrency), (account, category), + (title, description, titleSuggestions), (time, timeUi, hidden) -> + EditTrnState( + trnType = trnType, + amount = amount, + amountBaseCurrency = amountBaseCurrency, + account = account, + category = category, + timeUi = timeUi, + time = time, + title = title, + description = description, + hidden = hidden, + + titleSuggestions = titleSuggestions, + keyboardController = keyboardController, + ) + } + + private fun amountFlow() = amount.map { amount -> + exchangeInBaseCurrencyFlow(amount.value).map { amountBaseCurrency -> + amount to amountBaseCurrency + } + }.flattenLatest() + + private fun textsFlow() = combine( + title, description, category, + ) { title, description, category -> + titleSuggestionsFlow( + TitleSuggestionsFlow.Input( + title = title, + categoryUi = category, + transfer = false, + ) + ).map { titleSuggestions -> + Triple(title, description, titleSuggestions) + } + }.flattenLatest() + + private fun accountCategoryFlow() = combine( + account, category + ) { account, category -> + account to category + } + + private fun othersFlow() = combine( + time, timeUi, hidden + ) { time, timeUi, hidden -> + Triple(time, timeUi, hidden) + } + + + // region Event Handling + override suspend fun handleEvent(event: EditTrnEvent) = when (event) { + is EditTrnEvent.Initial -> handleInitial(event) + is EditTrnEvent.Save -> handleSave() + is EditTrnEvent.Delete -> handleDelete() + is EditTrnEvent.Close -> handleClose() + is EditTrnEvent.AccountChange -> handleAccountChange(event) + is EditTrnEvent.AmountChange -> handleAmountChange(event) + is EditTrnEvent.CategoryChange -> handleCategoryChange(event) + is EditTrnEvent.DescriptionChange -> handleDescriptionChange(event) + is EditTrnEvent.TitleChange -> handleTitleChange(event) + is EditTrnEvent.TrnTimeChange -> handleTrnTimeChange(event) + is EditTrnEvent.TrnTypeChange -> handleTrnTypeChange(event) + is EditTrnEvent.HiddenChange -> handleHiddenChange(event) + } + + private suspend fun handleInitial(event: EditTrnEvent.Initial) { + val transaction = event.trnId.toUUIDOrNull()?.let { trnId -> + trnByIdAct(trnId) + } + + if (transaction != null) { + this.transaction = transaction + trnType.value = transaction.type + amount.value = CombinedValueUi( + value = transaction.value, + shortenFiat = false, + ) + account.value = mapAccountUiAct(transaction.account) + category.value = transaction.category?.let { category -> + mapCategoryUiAct(category) + } + time.value = transaction.time + timeUi.value = mapTrnTimeUiAct(transaction.time) + title.value = transaction.title + description.value = transaction.description.takeIf { it.isNotNullOrBlank() } + } else { + closeScreen() + } + } + + private suspend fun handleSave() { + val original = transaction ?: return + val account = accountByIdAct(account.value.id) ?: return + val category = category.value?.id?.let { categoryByIdAct(it) } + + if (amount.value.value.amount <= 0.0) return + + val updated = original.copy( + account = account, + category = category, + value = amount.value.value, + time = time.value, + title = title.value.takeIf { it.isNotNullOrBlank() }, + description = description.value.takeIf { it.isNotNullOrBlank() }, + type = trnType.value, + state = if (hidden.value) TrnState.Hidden else TrnState.Default, + sync = Sync( + state = SyncState.Syncing, + lastUpdated = timeProvider.timeNow(), + ), + ) + + writeTrnsAct( + WriteTrnsAct.Input.Update( + old = original, + new = updated, + ) + ) + closeScreen() + } + + private suspend fun handleDelete() { + val original = transaction ?: return + writeTrnsAct( + WriteTrnsAct.Input.Delete( + trnId = original.id.toString(), + affectedAccountIds = setOf(original.account.id.toString(), account.value.id), + originalTime = original.time, + ) + ) + closeScreen() + } + + private fun handleClose() { + closeScreen() + } + + private fun closeScreen() { + keyboardController.hide() + navigator.back() + } + + // region Handle value changes + private fun handleAccountChange(event: EditTrnEvent.AccountChange) { + account.value = event.account + } + + private fun handleCategoryChange(event: EditTrnEvent.CategoryChange) { + category.value = event.category + } + + private fun handleAmountChange(event: EditTrnEvent.AmountChange) { + amount.value = CombinedValueUi( + value = event.amount, + shortenFiat = false + ) + } + + private fun handleTitleChange(event: EditTrnEvent.TitleChange) { + title.value = event.title.takeIf { it.isNotEmpty() } + } + + private fun handleDescriptionChange(event: EditTrnEvent.DescriptionChange) { + description.value = event.description.takeIf { it.isNotEmpty() } + } + + private suspend fun handleTrnTimeChange(event: EditTrnEvent.TrnTimeChange) { + time.value = event.time + timeUi.value = mapTrnTimeUiAct(event.time) + } + + private fun handleTrnTypeChange(event: EditTrnEvent.TrnTypeChange) { + trnType.value = event.trnType + } + + private fun handleHiddenChange(event: EditTrnEvent.HiddenChange) { + hidden.value = event.hidden + } + // endregion + // endregion +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/edit/trn/EditTrnEvent.kt b/transaction/src/main/java/com/ivy/transaction/edit/trn/EditTrnEvent.kt new file mode 100644 index 0000000..80d7434 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/edit/trn/EditTrnEvent.kt @@ -0,0 +1,23 @@ +package com.ivy.transaction.edit.trn + +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.data.Value +import com.ivy.data.transaction.TransactionType +import com.ivy.data.transaction.TrnTime + +sealed interface EditTrnEvent { + data class Initial(val trnId: String) : EditTrnEvent + object Delete : EditTrnEvent + object Save : EditTrnEvent + object Close : EditTrnEvent + + data class AmountChange(val amount: Value) : EditTrnEvent + data class TitleChange(val title: String) : EditTrnEvent + data class DescriptionChange(val description: String?) : EditTrnEvent + data class AccountChange(val account: AccountUi) : EditTrnEvent + data class CategoryChange(val category: CategoryUi?) : EditTrnEvent + data class TrnTypeChange(val trnType: TransactionType) : EditTrnEvent + data class TrnTimeChange(val time: TrnTime) : EditTrnEvent + data class HiddenChange(val hidden: Boolean) : EditTrnEvent +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/edit/trn/EditTrnState.kt b/transaction/src/main/java/com/ivy/transaction/edit/trn/EditTrnState.kt new file mode 100644 index 0000000..c852968 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/edit/trn/EditTrnState.kt @@ -0,0 +1,29 @@ +package com.ivy.transaction.edit.trn + +import androidx.compose.runtime.Immutable +import com.ivy.core.domain.pure.format.CombinedValueUi +import com.ivy.core.domain.pure.format.ValueUi +import com.ivy.core.ui.data.CategoryUi +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.transaction.TrnTimeUi +import com.ivy.data.transaction.TransactionType +import com.ivy.data.transaction.TrnTime +import com.ivy.design.util.KeyboardController + +@Immutable +data class EditTrnState( + val trnType: TransactionType, + val amount: CombinedValueUi, + val amountBaseCurrency: ValueUi?, + val account: AccountUi, + val category: CategoryUi?, + val timeUi: TrnTimeUi, + val time: TrnTime, + val title: String?, + val description: String?, + val hidden: Boolean, + + val titleSuggestions: List, + + val keyboardController: KeyboardController, +) \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/modal/AmountModalWithAccounts.kt b/transaction/src/main/java/com/ivy/transaction/modal/AmountModalWithAccounts.kt new file mode 100644 index 0000000..24fdcfe --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/modal/AmountModalWithAccounts.kt @@ -0,0 +1,62 @@ +package com.ivy.transaction.modal + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.domain.pure.dummy.dummyValue +import com.ivy.core.ui.account.pick.SingleAccountPickerRow +import com.ivy.core.ui.amount.AmountModal +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.data.Value +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.previewModal +import com.ivy.design.util.IvyPreview + +@Composable +fun BoxScope.AmountModalWithAccounts( + modal: IvyModal, + amount: Value?, + account: AccountUi, + level: Int = 1, + key: String? = null, + onAddAccount: () -> Unit, + onAmountEnter: (Value) -> Unit, + onAccountChange: (AccountUi) -> Unit, +) { + AmountModal( + modal = modal, + level = level, + key = key, + initialAmount = amount, + contentAbove = { + SpacerVer(height = 24.dp) + SingleAccountPickerRow( + selected = account, + onAddAccount = onAddAccount, + onSelectedChange = onAccountChange + ) + SpacerVer(height = 12.dp) + }, + onAmountEnter = onAmountEnter + ) +} + + +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = previewModal() + AmountModalWithAccounts( + modal = modal, + amount = dummyValue(), + account = dummyAccountUi(), + onAddAccount = {}, + onAmountEnter = {}, + onAccountChange = {} + ) + } +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/modal/DescriptionModal.kt b/transaction/src/main/java/com/ivy/transaction/modal/DescriptionModal.kt new file mode 100644 index 0000000..e3cf9d0 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/modal/DescriptionModal.kt @@ -0,0 +1,99 @@ +package com.ivy.transaction.modal + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.* +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.input.InputFieldType +import com.ivy.design.l2_components.input.IvyInputField +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Positive +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.previewModal +import com.ivy.design.l3_ivyComponents.button.DeleteButton +import com.ivy.design.util.IvyPreview +import com.ivy.transaction.R + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun BoxScope.DescriptionModal( + modal: IvyModal, + initialDescription: String?, + level: Int = 1, + onDescriptionChange: (String?) -> Unit, +) { + var description by remember { + mutableStateOf(initialDescription) + } + + val keyboardController = LocalSoftwareKeyboardController.current + Modal( + modal = modal, + level = level, + actions = { + if (description != null) { + DeleteButton { + onDescriptionChange(null) + modal.hide() + } + SpacerHor(width = 8.dp) + } + Positive( + text = if (description != null) + stringResource(R.string.add) else stringResource(R.string.save) + ) { + keyboardController?.hide() + onDescriptionChange(description) + modal.hide() + } + } + ) { + Title(text = stringResource(R.string.description)) + SpacerVer(height = 24.dp) + + val focus = remember { FocusRequester() } + IvyInputField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focus) + .padding(horizontal = 16.dp), + type = InputFieldType.Multiline(), + initialValue = description ?: "", + placeholder = stringResource(R.string.description_text_field_hint), + onValueChange = { + description = it + } + ) + LaunchedEffect(Unit) { + focus.requestFocus() + } + + SpacerVer(height = 24.dp) + } +} + + +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = previewModal() + DescriptionModal( + modal = modal, + initialDescription = "", + onDescriptionChange = {} + ) + } + +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/modal/FeeModal.kt b/transaction/src/main/java/com/ivy/transaction/modal/FeeModal.kt new file mode 100644 index 0000000..d9eb173 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/modal/FeeModal.kt @@ -0,0 +1,109 @@ +package com.ivy.transaction.modal + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.amount.AmountModal +import com.ivy.data.Value +import com.ivy.design.l0_system.UI +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.previewModal +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.DeleteButton +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview + +@Composable +fun BoxScope.FeeModal( + modal: IvyModal, + fee: Value?, + level: Int = 1, + onRemoveFee: () -> Unit, + onFeePercent: (Double) -> Unit, + onFeeChange: (Value) -> Unit, +) { + AmountModal( + modal = modal, + level = level, + key = "fee", + initialAmount = fee, + contentAbove = { + val feePercents = remember { + listOf( + "0.25%" to 0.0025, + "0.5%" to 0.005, + "0.75%" to 0.0075, + "1%" to 0.01, + "1.25%" to 0.0125, + "1.5%" to 0.015, + "1.6%" to 0.016, + "1.75%" to 0.0175, + "1.8%" to 0.018, + "2%" to 0.02, + ) + } + + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + ) { + items( + items = feePercents, + key = { it.first } + ) { (percentText, percentValue) -> + SpacerHor(8.dp) + IvyButton( + size = ButtonSize.Small, + visibility = Visibility.Medium, + feeling = Feeling.Positive, + typo = UI.typoSecond.b2, + fontWeight = FontWeight.Normal, + text = percentText, + icon = null, + ) { + onFeePercent(percentValue) + } + } + item(key = "last_item_spacer") { + SpacerHor(12.dp) + } + } + }, + moreActions = { + DeleteButton { + onRemoveFee() + modal.hide() + } + SpacerHor(width = 12.dp) + }, + onAmountEnter = onFeeChange + ) +} + + +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = previewModal() + FeeModal( + modal = modal, + fee = null, + onRemoveFee = {}, + onFeeChange = {}, + onFeePercent = {} + ) + } +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/modal/TransferAmountModal.kt b/transaction/src/main/java/com/ivy/transaction/modal/TransferAmountModal.kt new file mode 100644 index 0000000..d3974bb --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/modal/TransferAmountModal.kt @@ -0,0 +1,86 @@ +package com.ivy.transaction.modal + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.domain.pure.dummy.dummyValue +import com.ivy.core.ui.account.pick.SingleAccountPickerRow +import com.ivy.core.ui.amount.AmountModal +import com.ivy.core.ui.data.account.AccountUi +import com.ivy.core.ui.data.account.dummyAccountUi +import com.ivy.data.Value +import com.ivy.design.l1_buildingBlocks.B2 +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.previewModal +import com.ivy.design.util.IvyPreview + +@Composable +fun BoxScope.TransferAmountModal( + modal: IvyModal, + level: Int = 1, + amount: Value?, + fromAccount: AccountUi, + toAccount: AccountUi, + onAddAccount: () -> Unit, + onAmountEnter: (Value) -> Unit, + onFromAccountChange: (AccountUi) -> Unit, + onToAccountChange: (AccountUi) -> Unit, +) { + AmountModal( + modal = modal, + level = level, + initialAmount = amount, + contentAbove = { + SpacerVer(height = 16.dp) + B2( + modifier = Modifier.padding(start = 32.dp), + text = "From", + fontWeight = FontWeight.SemiBold + ) + SpacerVer(height = 4.dp) + SingleAccountPickerRow( + selected = fromAccount, + onAddAccount = onAddAccount, + onSelectedChange = onFromAccountChange + ) + SpacerVer(height = 8.dp) + B2( + modifier = Modifier.padding(start = 32.dp), + text = "To", + fontWeight = FontWeight.SemiBold + ) + SpacerVer(height = 4.dp) + SingleAccountPickerRow( + selected = toAccount, + onAddAccount = onAddAccount, + onSelectedChange = onToAccountChange + ) + SpacerVer(height = 12.dp) + }, + onAmountEnter = onAmountEnter + ) +} + + +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = previewModal() + TransferAmountModal( + modal = modal, + amount = dummyValue(), + fromAccount = dummyAccountUi(), + toAccount = dummyAccountUi(), + onAddAccount = {}, + onAmountEnter = {}, + onFromAccountChange = {}, + onToAccountChange = {} + ) + } +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/modal/TrnDateModal.kt b/transaction/src/main/java/com/ivy/transaction/modal/TrnDateModal.kt new file mode 100644 index 0000000..560a7d5 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/modal/TrnDateModal.kt @@ -0,0 +1,123 @@ +package com.ivy.transaction.modal + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.common.time.time +import com.ivy.core.domain.pure.dummy.dummyActual +import com.ivy.core.ui.time.picker.date.DatePickerModal +import com.ivy.data.transaction.TrnTime +import com.ivy.design.l0_system.color.Orange +import com.ivy.design.l1_buildingBlocks.SpacerHor +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.previewModal +import com.ivy.design.l3_ivyComponents.Feeling +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview + +private enum class TrnTimeTypeLocal { + Actual, Due +} + +@Composable +fun BoxScope.TrnDateModal( + modal: IvyModal, + trnTime: TrnTime, + level: Int = 1, + onTrnTimeChange: (TrnTime) -> Unit, +) { + var type by remember { + mutableStateOf( + when (trnTime) { + is TrnTime.Actual -> TrnTimeTypeLocal.Actual + is TrnTime.Due -> TrnTimeTypeLocal.Due + } + ) + } + + DatePickerModal( + modal = modal, + level = level, + selected = trnTime.time().toLocalDate(), + contentTop = { + SpacerVer(height = 16.dp) + TrnTimeTypeSelector( + type = type, + onTypeChange = { type = it } + ) + }, + onPick = { date -> + val time = trnTime.time().toLocalTime() + onTrnTimeChange( + when (type) { + TrnTimeTypeLocal.Actual -> TrnTime.Actual(date.atTime(time)) + TrnTimeTypeLocal.Due -> TrnTime.Due(date.atTime(time)) + } + ) + } + ) +} + +@Composable +private fun TrnTimeTypeSelector( + type: TrnTimeTypeLocal, + onTypeChange: (TrnTimeTypeLocal) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IvyButton( + modifier = Modifier.weight(1f), + size = ButtonSize.Small, + visibility = when (type) { + TrnTimeTypeLocal.Actual -> Visibility.High + TrnTimeTypeLocal.Due -> Visibility.Medium + }, + feeling = Feeling.Positive, + text = "Actual", + icon = null + ) { + onTypeChange(TrnTimeTypeLocal.Actual) + } + SpacerHor(width = 16.dp) + IvyButton( + modifier = Modifier.weight(1f), + size = ButtonSize.Small, + visibility = when (type) { + TrnTimeTypeLocal.Actual -> Visibility.Medium + TrnTimeTypeLocal.Due -> Visibility.High + }, + feeling = Feeling.Custom(Orange), + text = "Due", + icon = null + ) { + onTypeChange(TrnTimeTypeLocal.Due) + } + } +} + + +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = previewModal() + TrnDateModal( + modal = modal, + trnTime = dummyActual(), + onTrnTimeChange = {} + ) + } +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/modal/TrnTimeModal.kt b/transaction/src/main/java/com/ivy/transaction/modal/TrnTimeModal.kt new file mode 100644 index 0000000..007f0ec --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/modal/TrnTimeModal.kt @@ -0,0 +1,60 @@ +package com.ivy.transaction.modal + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.ivy.common.time.time +import com.ivy.core.domain.pure.dummy.dummyActual +import com.ivy.core.ui.time.picker.time.TimePickerModal +import com.ivy.data.transaction.TrnTime +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.previewModal +import com.ivy.design.util.IvyPreview + + +@Composable +fun BoxScope.TrnTimeModal( + modal: IvyModal, + trnTime: TrnTime, + level: Int = 1, + onTrnTimeChange: (TrnTime) -> Unit, +) { + TimePickerModal( + modal = modal, + level = level, + selected = trnTime.time().toLocalTime(), + onPick = { time -> + onTrnTimeChange( + when (trnTime) { + is TrnTime.Actual -> TrnTime.Actual( + trnTime.actual + .withHour(time.hour) + .withMinute(time.minute) + .withSecond(0) + .withNano(0) + ) + is TrnTime.Due -> TrnTime.Due( + trnTime.due + .withHour(time.hour) + .withMinute(time.minute) + .withSecond(0) + .withNano(0) + ) + } + ) + } + ) +} + +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = previewModal() + TrnTimeModal( + modal = modal, + trnTime = dummyActual(), + onTrnTimeChange = {} + ) + } +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/modal/TrnTypeModal.kt b/transaction/src/main/java/com/ivy/transaction/modal/TrnTypeModal.kt new file mode 100644 index 0000000..7784792 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/modal/TrnTypeModal.kt @@ -0,0 +1,92 @@ +package com.ivy.transaction.modal + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ivy.core.ui.transaction.feeling +import com.ivy.core.ui.transaction.humanText +import com.ivy.core.ui.transaction.icon +import com.ivy.data.transaction.TransactionType +import com.ivy.design.l1_buildingBlocks.SpacerVer +import com.ivy.design.l2_components.modal.IvyModal +import com.ivy.design.l2_components.modal.Modal +import com.ivy.design.l2_components.modal.components.Title +import com.ivy.design.l2_components.modal.previewModal +import com.ivy.design.l3_ivyComponents.Visibility +import com.ivy.design.l3_ivyComponents.button.ButtonSize +import com.ivy.design.l3_ivyComponents.button.IvyButton +import com.ivy.design.util.IvyPreview + +@Composable +fun BoxScope.TrnTypeModal( + modal: IvyModal, + trnType: TransactionType, + level: Int = 1, + onTransactionTypeChange: (TransactionType) -> Unit, +) { + var selectedTrnType by remember(trnType) { + mutableStateOf(trnType) + } + + Modal( + modal = modal, + level = level, + actions = {}, + ) { + val onSelect = { trnType: TransactionType -> + selectedTrnType = trnType + onTransactionTypeChange(trnType) + modal.hide() + } + + Title(text = "Transaction Type") + SpacerVer(height = 24.dp) + TransactionTypeButton( + trnType = TransactionType.Income, + selected = selectedTrnType, + onSelect = onSelect + ) + SpacerVer(height = 12.dp) + TransactionTypeButton( + trnType = TransactionType.Expense, + selected = selectedTrnType, + onSelect = onSelect + ) + SpacerVer(height = 24.dp) + } +} + +@Composable +private fun TransactionTypeButton( + trnType: TransactionType, + selected: TransactionType, + onSelect: (TransactionType) -> Unit +) { + IvyButton( + modifier = Modifier.padding(horizontal = 16.dp), + size = ButtonSize.Big, + visibility = if (trnType == selected) Visibility.High else Visibility.Medium, + feeling = trnType.feeling(), + text = trnType.humanText(), + icon = trnType.icon() + ) { + onSelect(trnType) + } +} + + +@Preview +@Composable +private fun Preview() { + IvyPreview { + val modal = previewModal() + TrnTypeModal( + modal = modal, + trnType = TransactionType.Income, + onTransactionTypeChange = {} + ) + } +} \ No newline at end of file diff --git a/transaction/src/main/java/com/ivy/transaction/pure/SuggestTitle.kt b/transaction/src/main/java/com/ivy/transaction/pure/SuggestTitle.kt new file mode 100644 index 0000000..2e958d3 --- /dev/null +++ b/transaction/src/main/java/com/ivy/transaction/pure/SuggestTitle.kt @@ -0,0 +1,46 @@ +package com.ivy.transaction.pure + +import com.ivy.common.Quadruple +import com.ivy.common.isNotNullOrBlank +import com.ivy.data.transaction.Transaction + +private const val MAX_SUGGESTIONS = 10 + +fun suggestTitle( + transactions: List, + title: String?, +): List { + val inputQuery = searchQuery(title) ?: "" + + return transactions.asSequence() // improve performance + .filter { it.title.isNotNullOrBlank() } + .groupBy { searchQuery(it.title) } + .map { (trnQuery, trns) -> + Triple(trnQuery, trns.size, trns.first()) + }.map { (trnQuery, trnsCount, trn) -> + val exactMatch = if (inputQuery.isNotBlank()) + trnQuery?.contains(inputQuery) ?: false + else false + Quadruple( + exactMatch, trnQuery, trnsCount, trn + ) + }.sortedWith( + compareByDescending { (exactMatch, _, trnsCount, _) -> + // exact matches must come first + if (exactMatch) trnsCount * 10_000 else trnsCount + } + ) + .mapNotNull { (_, _, _, trn) -> + // return the original transaction's title + trn.title + } + .filter { suggestedTitle -> + // don't show duplicated suggestions + suggestedTitle != title + } + .toList() + .take(MAX_SUGGESTIONS) +} + +fun searchQuery(query: String?): String? = + query?.trim()?.lowercase()?.takeIf { it.isNotBlank() } \ No newline at end of file diff --git a/verify.sh b/verify.sh new file mode 100755 index 0000000..f805dc9 --- /dev/null +++ b/verify.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +echoError() { + echo -e "\033[1;31m$1\033[0m" +} + +echoSuccess() { + echo -e "\033[1;32m$1\033[0m" +} + +echoPending() { + echo -e "\033[1;33m$1\033[0m" +} + +echo "Ivy build verification started." +echoError "WARNING: Run on Android 9 or above. (Mockk works only on Android 9+)" +echoPending "Running Unit tests..." +./gradlew testDebugUnitTest || { + echoError "IVY VERIFICATION: UNIT TESTING failed." + exit +} +echoSuccess "IVY VERIFICATION: UNIT TESTS PASSED SUCCESSFULLY" +echoPending "Running UI tests..." +./gradlew connectedDebugAndroidTest --continue +echoPending "Instrumentation tests completed. Report opened in the default browser." +google-chrome "app/build/reports/androidTests/connected/index.html" || open "app/build/reports/androidTests/connected/index.html" +echoSuccess "AndroidTest results opened in the browser." \ No newline at end of file diff --git a/widgets/.gitignore b/widgets/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/widgets/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/widgets/README.md b/widgets/README.md new file mode 100644 index 0000000..bb4f4f3 --- /dev/null +++ b/widgets/README.md @@ -0,0 +1,19 @@ +# Widgets + +Ivy Wallet's widget implemented via Glance - Jetpack Compose for widgets. + +## 🚧 Module under construction... + +If it hardly works, it's filled with bad code and anti-patterns anyway... + +### To see how a proper should look like refer to: + +- **[:core](../core)**: responsible for Ivy Wallet's domain +- **[:home](../home/)**: Ivy wallet's home screen. + +Apologies for the inconvenience! I'm a solo dev + the help of our awesome Ivy contributors. If you +want to support us: + +1. Star our repo. + [![GitHub Repo stars](https://img.shields.io/github/stars/Ivy-Apps/ivy-wallet?style=social)](https://github.com/Ivy-Apps/ivy-wallet/stargazers) +2. Have a look at our [Contributors Guide](../CONTRIBUTING.md). \ No newline at end of file diff --git a/widgets/build.gradle.kts b/widgets/build.gradle.kts new file mode 100644 index 0000000..2ca08c8 --- /dev/null +++ b/widgets/build.gradle.kts @@ -0,0 +1,27 @@ +import com.ivy.buildsrc.DataStore +import com.ivy.buildsrc.Glance +import com.ivy.buildsrc.Hilt + +apply() + +plugins { + `android-library` + `kotlin-android` +} + +android { + buildFeatures { + compose = true + } +} + +dependencies { + Hilt() + implementation(project(":common:main")) + implementation(project(":design-system")) + implementation(project(":core:data-model")) + implementation(project(":core:ui")) + Glance() + + DataStore(api = false) +} \ No newline at end of file diff --git a/widgets/src/main/AndroidManifest.xml b/widgets/src/main/AndroidManifest.xml new file mode 100644 index 0000000..68c2f21 --- /dev/null +++ b/widgets/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/widgets/src/main/res/layout/widget_add_transaction.xml b/widgets/src/main/res/layout/widget_add_transaction.xml new file mode 100644 index 0000000..ca99782 --- /dev/null +++ b/widgets/src/main/res/layout/widget_add_transaction.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/widgets/src/main/res/layout/widget_add_transaction_compact.xml b/widgets/src/main/res/layout/widget_add_transaction_compact.xml new file mode 100644 index 0000000..c031d7a --- /dev/null +++ b/widgets/src/main/res/layout/widget_add_transaction_compact.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/widgets/src/main/res/xml/add_transaction_widget_compact_info.xml b/widgets/src/main/res/xml/add_transaction_widget_compact_info.xml new file mode 100644 index 0000000..9cf2092 --- /dev/null +++ b/widgets/src/main/res/xml/add_transaction_widget_compact_info.xml @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/widgets/src/main/res/xml/add_transaction_widget_info.xml b/widgets/src/main/res/xml/add_transaction_widget_info.xml new file mode 100644 index 0000000..963c39e --- /dev/null +++ b/widgets/src/main/res/xml/add_transaction_widget_info.xml @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/widgets/src/main/res/xml/wallet_balance_widget_info.xml b/widgets/src/main/res/xml/wallet_balance_widget_info.xml new file mode 100644 index 0000000..53322d8 --- /dev/null +++ b/widgets/src/main/res/xml/wallet_balance_widget_info.xml @@ -0,0 +1,10 @@ + + \ No newline at end of file