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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ xmlns:android
+
+ ^$
+
+
+
+
+
+
+
+
+ xmlns:.*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*:id
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ .*:name
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ name
+
+ ^$
+
+
+
+
+
+
+
+
+ style
+
+ ^$
+
+
+
+
+
+
+
+
+ .*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*
+
+ http://schemas.android.com/apk/res/android
+
+
+ ANDROID_ATTRIBUTE_ORDER
+
+
+
+
+
+
+ .*
+
+ .*
+
+
+ BY_NAME
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+[](https://github.com/Ivy-Apps/ivy-wallet/releases)
+[](https://github.com/Ivy-Apps/ivy-wallet/actions/workflows/build.yml)
+[](https://github.com/Ivy-Apps/ivy-wallet/actions/workflows/lint.yml)
+[](https://github.com/Ivy-Apps/ivy-wallet/actions/workflows/unit_test.yml)
+
+
+[](https://www.gnu.org/licenses/gpl-3.0)
+[](https://github.com/Ivy-Apps/ivy-wallet/blob/main/CONTRIBUTING.md)
+[](https://github.com/Ivy-Apps/ivy-wallet/stargazers)
+[](https://github.com/sponsors/Ivy-Apps)
+
+# [Ivy Wallet: money manager](https://play.google.com/store/apps/details?id=com.ivy.wallet)
+
+| | | | |
+| :---: | :----: | :---: | :---: |
+|  |  |  | 
+|  |  | | 
+
+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.
+
+
+
+> To support our free, open-source project please ⭐ star our repo - that means a lot for us! Thank you! [](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)** [](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! 🚀
+
+[](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 [](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 [](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 @@
+
+RawAccStatsFlow Database Dependencies +
+calcTrnDao
+CalcTrnDao +
+rawStatsFlow
+RawStatsFlow +
+accountsCacheDao
+AccountCacheDao Input +
+accountId
+String Lookup for cache by `account_id`. Cache found? Cache found. Cache NOT found. RawStats +
+incomes
+Map<CurrencyCode, Double> +
+expenses
+Map<CurrencyCode, Double> +
+incomesCount
+Int +
+expensesCount
+Int accounts_cache account_id
+String
+PK incomes_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` accountId true false Cache (from DB) Processed (slow) accountsCacheDao calcTrnDao rawStatsFlow AccountsCacheDao
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+WriteAccountsAct WriteTrnsAct UpdateAccCacheAct Database Deletie account Delete transaction Create transaction Update transaction On: Account deleted On: Transaction updated/deleted accounts_cache account_id
+String
+PK other
+Doesn't matter
+Invalidate the cache for that account cuz it's deleted. Input Cache 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
+TrnTime CreateTrnInfo +
+time
+TrnTime UpdateTrnInfo +
+oldTime
+TrnTime +
+time
+TrnTime Is 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? Triggers Triggers Triggers Triggers AND Nope Found! True False Delete by account.id Delete 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:persistence core-data-model :sync :backup :drive:google-drive automate:sms-parser automate:notification-parser Compose UI UI State Event ViewModel Actions (impure) Abstraction Barrier UI Components: (impure) Map Domain to UI data Abstraction Barrier (exposed) Actions (impure) Caculations Abstraction Barrier (exposed) Provider Abstraction Barrier (exposed) Actions (impure) Validation layer (pure) Relational Database DataStore Domain data Optimzed Fetches Backup JSON from Google Drive Merges the data with the local DB Uploads data to Google Drive Export backup JSON Import backup JSON Import old backup JSON GoogleDriveService: get & upload data to drive Parses transactions from SMS Automatically adds valid transactions Parses transactions from Notificaitons Automatically adds valid transactions Usually a Screen Destination in the Navigation graph Data class only of Compose primitives ADT with all possible user interactions Produces UI State Handles Event Feature-level actions Read Actions (impure) Calculations (pure) Compose UI Data -> DataUi (e.g. Transaction -> TransactionUi) Write Actions (impure) Read Actions (impure) Provide DI Execute Effects Pure processing layer RemoteExchangeProvider Fetches remote exchange rates for a base-currency Calculations Read Actions (impure) Write Actions (impure) DAOs DataStore Ensures data being written is correct Ensures data being read is correct SQL tables Key-Value Storage ADTs best describing the domain example: Transaction Select only the needed parts of the domain data example: TrnHistory example: TrnCalc Flow (RX) E.g. format: Value -> Boolean -> ValueUi Common UI Components Modals suspend Flow (RX) suspend (snapshot) suspend fun fetchExchangeRates(baseCurrency: CurrencyCode): Result Filter only valid rates Convert to base currency Flow (RX) suspend (snapshot) suspend Latest state User interactions Produced by the ViewModel Sent to the ViewModel Feauture-level logic @Composables Read-only Flows (RX) + Calculations (pure) Write Actions (impure) + Reading Snapshots (impure) Read-only Flows (RX) Domain logic Write/Read from persistence as Domain Fetch remote exchange rates Uses Uses Read 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 @@
+
+CalcTrnDao RawStatsFlow RatesDao RateOverrideDao RatesFlow ExchangeStatsFlow Query SQL: O(trns.count) time | O(trns.notDel.count) space List<CalcTrn> Input +
+trns
+List<CalcTrn> Process: O(trns.count) time | O(currs.unique.count) space RawStats +
+incomes
+Map<CurrencyCode, Double> +
+expenses
+Map<CurrencyCode, Double> +
+incomesCount
+Int +
+expensesCount
+Int SQL: O(rates.count) time | O(rates.baseCurr.count) space List<Rate> SQL: O(rates.override.count) time | O(rates.override.baseCurr.count) space List<Rate> Dependencies Process: O(rates.override.count) time | O(1) space RatesData +
+baseCurrency
+String +
+rates
+Map<CurrencyCode, Double> Dependencies Input +
+rawStats
+RawStats +
+outputCurrency
+String Process: O(curr.unique.count) space-time Stats +
+income
+Value +
+expense
+Value +
+incomesCount
+Int +
+expensesCount
+Int ByTime ByCategory ByAccount ByPurpose SELECT amount, currency, type FROM transactions WHERE ... CalcTrn +
+amount
+Double +
+currency
+String +
+type
+TransactionType trns.forEach { aggregate incomes, expense by currencies + count them } SELECT rate, currency FROM exchange_rates WHERE baseCurrency = ? Rate +
+rate
+Double +
+currency
+String SELECT rate, currency FROM exchange_rates_override WHERE baseCurrency = ? AND sync != $DELETING Rate +
+rate
+Double +
+currency
+String ratesDao ratesOverrideDao baseCurrencyFlow baseCurrency.flatMapLatest {} combine(rateDao.findByBaseCurr(), ratesOverridedao.findByBaseCurr()) Override rate with the manual set ones rates: RatesFlow rawStats.incomes.forEach {} exchange to output currency sum & count rawStats.expenses.forEach {} exchange to output currency sum & count Reacts Reacts Reacts Reacts to rates changes Reacts
+
+
+
+
+
+
+
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 @@
+
+accounts id
+uuid
+PK 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)
+sync item_id
+uuid
+FK state
+int: Syncing | Deleted | Synced
+last_updated
+long: epoch seconds
+categories id
+uuid
+PK name
+string
+icon_id
+string
+color_int
+int
+order_num
+double
+parent_category_id
+uuid (optional)
+FK trn_tag_join transaction_id
+uuid
+PK tag_id
+uuid
+PK tags id
+uuid
+PK name
+string
+color_int
+int
+order_num
+double
+trn_attachement_join attachment_id
+uuid
+PK tag_id
+uuid
+PK attachments id
+uuid
+PK transaction_id
+uuid
+FK uri
+string
+name
+string
+recurring_tag_join recurring_rule_id
+uuid
+PK tag_id
+uuid
+PK recurring_attachment_join recurring_rule_id
+uuid
+PK attachment_id
+uuid
+PK recurring_rules id
+uuid
+PK 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)
+FK title
+string (optional)
+description
+string (optional)
+ledger entry_id
+uuid
+PK from_account
+uuid (optional)
+FK from_amount
+positive double (optional)
+from_asset_code
+string (optional)
+to_account
+uuid (optional)
+FK to_amount
+positive double (optional)
+to_asset_code
+string (optional)
+timestamp
+long: epoch seconds
+special_purpose
+int: None(0) | Fee(1)
+transaction_id
+uuid
+FK transactions id
+uuid
+PK timestamp
+long: epoch seconds
+title
+string (optional)
+description
+string (optional)
+category_id
+uuid (optional)
+FK recurring_rule_id
+uuid (optional)
+FK hidden
+boolean
+due_ledger entry_id
+uuid
+PK from_account
+uuid (optional)
+FK from_amount
+positive double (optional)
+from_asset_code
+string (optional)
+to_account
+uuid (optional)
+FK to_amount
+positive double (optional)
+to_asset_code
+string (optional)
+timestamp
+long: epoch seconds
+special_purpose
+int: None(0) | Fee(1)
+transaction_id
+uuid
+FK due_transactions id
+uuid
+PK timestamp
+long: epoch seconds
+title
+string (optional)
+description
+string (optional)
+category_id
+uuid (optional)
+FK recurring_rule_id
+uuid (optional)
+FK account_cache account_id
+uuid
+PK incomes_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 @@
+
+TotalBalanceFlow WIP! 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